Phase 3: account linking and two-way chat relay

Test-first from here on (per request after Phase 2): every module below
was written test-then-implementation, confirmed red before green.

api: accounts/sessions/chat_messages tables, plus servers.anonymousChatAllowed.
Online accounts merge into one global identity per real Mojang uuid
(partial unique index on mc_uuid WHERE server_id IS NULL); offline accounts
are scoped per-server (partial unique index on (server_id, mc_uuid)) — see
schema.ts's accounts comment and link.test.ts's merge-scoping tests.

link.ts: storeLinkCode/redeemLinkCode (single-use, Redis-backed with a
10-minute TTL) and session lookup/revocation. Sessions come back in the
HTTP response body rather than an httpOnly cookie — a deliberate MVP
simplification (see link.ts's doc comment) that sidesteps needing to
verify exactly how Elysia's .ws() routes surface cookies; the client
sends the token back via X-MCMapper-Session.

chat.ts/chat-gateway.ts: mod-originated chat (ws-gateway.ts's new "chat"
and "link_request" message types) and browser-originated chat
(/ws/chat/:serverId) both persist to chat_messages and publish to a
per-server Redis pub/sub channel; browser chat additionally resolves
identity (linked session > nickname > rejected if anonymous chat is
disabled for that server) and forwards to the mod's own connection via a
new serverId->socket registry in ws-gateway.ts (getModSocket).

frontend: chat panel + link-code entry on the 2D map page, session token
kept in localStorage (matching the no-cookie tradeoff above).

Verified end-to-end against live containers, including through the real
mod-side Java client: a link code generated by DefaultBackendConnection
round-trips through actual HTTP redemption to the correct account, and a
browser chat message correctly forwards through to the mod's live
ChatListener callback (not just persisted/published).
This commit is contained in:
2026-08-08 17:08:23 +02:00
parent dc7185c15e
commit 6770bc23cc
14 changed files with 906 additions and 6 deletions
+32
View File
@@ -0,0 +1,32 @@
ALTER TABLE "servers" ADD COLUMN IF NOT EXISTS "anonymous_chat_allowed" boolean NOT NULL DEFAULT true;
CREATE TABLE IF NOT EXISTS "accounts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"mc_uuid" text NOT NULL,
"username" text NOT NULL,
"auth_mode" text NOT NULL,
"server_id" uuid REFERENCES "servers"("id") ON DELETE CASCADE,
"created_at" timestamptz NOT NULL DEFAULT now()
);
-- Online accounts merge into one global identity per real Mojang UUID.
CREATE UNIQUE INDEX IF NOT EXISTS "accounts_online_uuid" ON "accounts" ("mc_uuid") WHERE "server_id" IS NULL;
-- Offline accounts are scoped to the one server that issued the uuid hash.
CREATE UNIQUE INDEX IF NOT EXISTS "accounts_offline_server_uuid" ON "accounts" ("server_id", "mc_uuid") WHERE "server_id" IS NOT NULL;
CREATE TABLE IF NOT EXISTS "sessions" (
"token" text PRIMARY KEY NOT NULL,
"account_id" uuid NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE,
"created_at" timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS "chat_messages" (
"id" bigserial PRIMARY KEY NOT NULL,
"server_id" uuid NOT NULL REFERENCES "servers"("id") ON DELETE CASCADE,
"username" text NOT NULL,
"message" text NOT NULL,
"source" text NOT NULL,
"created_at" timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "chat_messages_server_created_idx" ON "chat_messages" ("server_id", "created_at");
+77
View File
@@ -0,0 +1,77 @@
import { redis } from "./redis";
import { chatChannel, recordAndPublishChat, resolveChatIdentity } from "./chat";
import { getModSocket } from "./ws-gateway";
// Wire protocol for browsers connecting to /ws/chat/:serverId, one JSON object per WS text
// frame:
//
// web -> api {"type":"chat","message":"...","sessionToken":"...","nickname":"..."}
// api -> web {"username":"...","message":"...","source":"game"|"web"} (fanned out via Redis
// pub/sub — see chat.ts's recordAndPublishChat, published verbatim so every
// connected browser, including the sender, sees the same message)
//
// `sessionToken`/`nickname` are both optional and both resolved fresh per message (no
// "identify" handshake step) — see chat.ts's resolveChatIdentity for the precedence rules.
// One shared subscriber connection for the whole process (not one per browser tab) — fans out
// to whichever browser sockets are currently registered for each server's channel.
const chatSubscriber = redis.duplicate();
const browserSocketsByServer = new Map<string, Set<any>>();
chatSubscriber.psubscribe("mcmapper:chat:*");
chatSubscriber.on("pmessage", (_pattern: string, channel: string, message: string) => {
const serverId = channel.slice("mcmapper:chat:".length);
const sockets = browserSocketsByServer.get(serverId);
if (!sockets) return;
for (const ws of sockets) ws.send(message);
});
function serverIdOf(ws: any): string {
return ws.data.params.serverId;
}
export const chatGateway = {
open(ws: any) {
const serverId = serverIdOf(ws);
let sockets = browserSocketsByServer.get(serverId);
if (!sockets) {
sockets = new Set();
browserSocketsByServer.set(serverId, sockets);
}
sockets.add(ws);
},
async message(ws: any, raw: unknown) {
const serverId = serverIdOf(ws);
let msg: any;
try {
msg = typeof raw === "string" ? JSON.parse(raw) : raw;
} catch {
return;
}
if (msg.type !== "chat" || typeof msg.message !== "string" || !msg.message.trim()) return;
const identity = await resolveChatIdentity(serverId, {
sessionToken: msg.sessionToken,
nickname: msg.nickname,
});
if ("error" in identity) {
ws.send(JSON.stringify({ type: "error", error: identity.error }));
return;
}
await recordAndPublishChat(serverId, { username: identity.username, message: msg.message, source: "web" });
const modSocket = getModSocket(serverId);
if (modSocket) {
modSocket.send(JSON.stringify({ type: "chat", username: identity.username, message: msg.message }));
}
},
close(ws: any) {
browserSocketsByServer.get(serverIdOf(ws))?.delete(ws);
},
};
// Exported for anything that wants to sanity-check which channel a server maps to.
export { chatChannel };
+117
View File
@@ -0,0 +1,117 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { and, eq } from "drizzle-orm";
import { db } from "./db/client";
import { chatMessages, servers } from "./db/schema";
import { redis } from "./redis";
import { recordAndPublishChat, isAnonymousChatAllowed, resolveChatIdentity } from "./chat";
import { createTestServer, deleteTestServer, createTestSession } from "./test-helpers";
describe("recordAndPublishChat", () => {
let server: { id: string };
beforeAll(async () => {
server = await createTestServer("chat-record");
});
afterAll(async () => {
await deleteTestServer(server.id);
});
test("persists the message and publishes it on the server's channel", async () => {
const sub = redis.duplicate();
await sub.subscribe(`mcmapper:chat:${server.id}`);
const received = new Promise<string>((resolve) => {
sub.once("message", (_channel, message) => resolve(message));
});
await recordAndPublishChat(server.id, { username: "Steve", message: "hello world", source: "game" });
const publishedRaw = await received;
expect(JSON.parse(publishedRaw)).toEqual({ username: "Steve", message: "hello world", source: "game" });
sub.disconnect();
const rows = await db
.select()
.from(chatMessages)
.where(and(eq(chatMessages.serverId, server.id), eq(chatMessages.message, "hello world")));
expect(rows).toHaveLength(1);
expect(rows[0]!.source).toBe("game");
});
});
describe("isAnonymousChatAllowed", () => {
let server: { id: string };
beforeAll(async () => {
server = await createTestServer("chat-anon-toggle");
});
afterAll(async () => {
await deleteTestServer(server.id);
});
test("defaults to true", async () => {
expect(await isAnonymousChatAllowed(server.id)).toBe(true);
});
test("reflects a false override", async () => {
await db.update(servers).set({ anonymousChatAllowed: false }).where(eq(servers.id, server.id));
expect(await isAnonymousChatAllowed(server.id)).toBe(false);
});
test("an unknown server has no anonymous chat to allow", async () => {
expect(await isAnonymousChatAllowed(crypto.randomUUID())).toBe(false);
});
});
describe("resolveChatIdentity", () => {
let server: { id: string };
let anonAllowedFalseServer: { id: string };
beforeAll(async () => {
server = await createTestServer("chat-identity");
anonAllowedFalseServer = await createTestServer("chat-identity-no-anon");
await db
.update(servers)
.set({ anonymousChatAllowed: false })
.where(eq(servers.id, anonAllowedFalseServer.id));
});
afterAll(async () => {
await deleteTestServer(server.id);
await deleteTestServer(anonAllowedFalseServer.id);
});
test("a valid session token resolves to the linked account's username", async () => {
const { sessionToken } = await createTestSession("LinkedPlayer");
const identity = await resolveChatIdentity(server.id, { sessionToken });
expect(identity).toEqual({ username: "LinkedPlayer" });
});
test("no session, anonymous allowed, falls back to the given nickname", async () => {
const identity = await resolveChatIdentity(server.id, { nickname: "Guest42" });
expect(identity).toEqual({ username: "Guest42" });
});
test("no session, no nickname, anonymous allowed, defaults to Anonymous", async () => {
const identity = await resolveChatIdentity(server.id, {});
expect(identity).toEqual({ username: "Anonymous" });
});
test("an invalid session token falls back to anonymous rules rather than erroring", async () => {
const identity = await resolveChatIdentity(server.id, { sessionToken: "not-a-real-token", nickname: "Fallback" });
expect(identity).toEqual({ username: "Fallback" });
});
test("no session and anonymous chat disabled is rejected", async () => {
const identity = await resolveChatIdentity(anonAllowedFalseServer.id, { nickname: "Sneaky" });
expect(identity).toEqual({ error: "anonymous_chat_disabled" });
});
test("a valid session still works even when anonymous chat is disabled", async () => {
const { sessionToken } = await createTestSession("StillLinked");
const identity = await resolveChatIdentity(anonAllowedFalseServer.id, { sessionToken });
expect(identity).toEqual({ username: "StillLinked" });
});
});
+61
View File
@@ -0,0 +1,61 @@
import { eq } from "drizzle-orm";
import { db } from "./db/client";
import { chatMessages, servers } from "./db/schema";
import { redis } from "./redis";
import { getAccountForSession } from "./link";
export interface ChatEvent {
username: string;
message: string;
source: "game" | "web";
}
export function chatChannel(serverId: string): string {
return `mcmapper:chat:${serverId}`;
}
/** Persists a chat message and fans it out to every browser subscribed to this server's channel. */
export async function recordAndPublishChat(serverId: string, event: ChatEvent) {
await db.insert(chatMessages).values({
serverId,
username: event.username,
message: event.message,
source: event.source,
});
await redis.publish(chatChannel(serverId), JSON.stringify(event));
}
export async function isAnonymousChatAllowed(serverId: string): Promise<boolean> {
const [row] = await db
.select({ anonymousChatAllowed: servers.anonymousChatAllowed })
.from(servers)
.where(eq(servers.id, serverId))
.limit(1);
return row?.anonymousChatAllowed ?? false;
}
export type ChatIdentity = { username: string } | { error: string };
/**
* A linked session (if the token is valid) always wins over an anonymous nickname — a browser
* chat message carries both optionally, and this decides who gets credited. An invalid/expired
* token isn't treated as an error; it just falls through to the anonymous rules, since the
* common case (session expired mid-visit) shouldn't hard-fail a chat message the user can plainly
* see they're about to send.
*/
export async function resolveChatIdentity(
serverId: string,
identity: { sessionToken?: string; nickname?: string },
): Promise<ChatIdentity> {
if (identity.sessionToken) {
const account = await getAccountForSession(identity.sessionToken);
if (account) return { username: account.username };
}
if (!(await isAnonymousChatAllowed(serverId))) {
return { error: "anonymous_chat_disabled" };
}
const nickname = identity.nickname?.trim();
return { username: nickname || "Anonymous" };
}
+45 -1
View File
@@ -1,4 +1,4 @@
import { pgTable, uuid, text, integer, smallint, timestamp, primaryKey } from "drizzle-orm/pg-core"; import { pgTable, uuid, text, integer, smallint, boolean, timestamp, primaryKey, bigserial } from "drizzle-orm/pg-core";
// One row per registered MC server. Phase 1 has no admin registration API yet (Phase 6) — // One row per registered MC server. Phase 1 has no admin registration API yet (Phase 6) —
// rows are created by `bun run seed` from MCMAPPER_SEED_SERVER_NAME/_TOKEN env vars. // rows are created by `bun run seed` from MCMAPPER_SEED_SERVER_NAME/_TOKEN env vars.
@@ -7,6 +7,50 @@ export const servers = pgTable("servers", {
name: text("name").notNull(), name: text("name").notNull(),
token: text("token").notNull().unique(), token: text("token").notNull().unique(),
authMode: text("auth_mode").notNull().default("offline"), authMode: text("auth_mode").notNull().default("offline"),
// Phase 3: web chat is open to anonymous (unlinked) visitors by default; an admin can require
// linking per-server. No admin UI to flip this yet (Phase 6) — set directly in the DB for now.
anonymousChatAllowed: boolean("anonymous_chat_allowed").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
// A linked player identity. `serverId` is null for online-mode accounts (uuid is a real Mojang
// UUID, safe to merge into one global account across every online-mode server this backend
// manages) and set for offline-mode accounts (uuid is just a hash of the username, only
// trustworthy scoped to the one server that issued it) — see the plan's "Identity & Linking"
// section. The actual uniqueness rules (one global account per online uuid; one account per
// (server, uuid) for offline) are partial unique indexes in the migration SQL, not expressible
// in drizzle's schema DSL in a way worth fighting for one column.
export const accounts = pgTable("accounts", {
id: uuid("id").defaultRandom().primaryKey(),
mcUuid: text("mc_uuid").notNull(),
username: text("username").notNull(),
authMode: text("auth_mode").notNull(),
serverId: uuid("server_id").references(() => servers.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
// A persistent, revocable web session. The token is handed to the client in the redeem response
// body (not an httpOnly cookie) and sent back via the `X-MCMapper-Session` header — a deliberate
// Phase 3 MVP simplification (see api/src/link.ts's doc comment for the tradeoff) worth
// revisiting once there's an actually security-sensitive surface behind it (e.g. admin actions).
export const sessions = pgTable("sessions", {
token: text("token").primaryKey(),
accountId: uuid("account_id")
.notNull()
.references(() => accounts.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
// Chat history, both directions. `source` is 'game' (from the mod's ChatBridge.OutboundSink) or
// 'web' (from a browser's /ws/chat connection).
export const chatMessages = pgTable("chat_messages", {
id: bigserial("id", { mode: "number" }).primaryKey(),
serverId: uuid("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
username: text("username").notNull(),
message: text("message").notNull(),
source: text("source").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}); });
+76
View File
@@ -4,6 +4,7 @@ import { db } from "./db/client";
import { meshPointers, tilePointers } from "./db/schema"; import { meshPointers, tilePointers } from "./db/schema";
import { minio, TILE_BUCKET } from "./minio"; import { minio, TILE_BUCKET } from "./minio";
import { createTestServer, deleteTestServer } from "./test-helpers"; import { createTestServer, deleteTestServer } from "./test-helpers";
import { storeLinkCode } from "./link";
// Elysia's `.handle()` drives the app in-process against a plain Request/Response, without // Elysia's `.handle()` drives the app in-process against a plain Request/Response, without
// binding a real port — avoids racing a real running instance for the port (see MCMapper's // binding a real port — avoids racing a real running instance for the port (see MCMapper's
@@ -12,6 +13,16 @@ function get(path: string) {
return app.handle(new Request(`http://localhost${path}`)); return app.handle(new Request(`http://localhost${path}`));
} }
function post(path: string, body?: unknown, headers?: Record<string, string>) {
return app.handle(
new Request(`http://localhost${path}`, {
method: "POST",
headers: { "content-type": "application/json", ...headers },
body: body !== undefined ? JSON.stringify(body) : undefined,
}),
);
}
describe("GET /health", () => { describe("GET /health", () => {
test("reports ok", async () => { test("reports ok", async () => {
const res = await get("/health"); const res = await get("/health");
@@ -126,3 +137,68 @@ describe("GET /api/meshes/...", () => {
expect(res.status).toBe(404); expect(res.status).toBe(404);
}); });
}); });
describe("POST /api/link/redeem, GET /api/me, POST /api/unlink", () => {
let server: { id: string };
beforeAll(async () => {
server = await createTestServer("link-routes");
});
afterAll(async () => {
await deleteTestServer(server.id);
});
test("redeeming a valid code returns a session and account", async () => {
const code = crypto.randomUUID().slice(0, 6);
await storeLinkCode(code, server.id, "route-uuid-1", "RouteTester", "online");
const res = await post("/api/link/redeem", { code });
expect(res.status).toBe(200);
const body = (await res.json()) as any;
expect(body.ok).toBe(true);
expect(body.account.username).toBe("RouteTester");
expect(typeof body.sessionToken).toBe("string");
});
test("redeeming an invalid code fails with 400", async () => {
const res = await post("/api/link/redeem", { code: "not-a-real-code" });
expect(res.status).toBe(400);
const body = (await res.json()) as any;
expect(body.ok).toBe(false);
});
test("GET /api/me reflects the session from X-MCMapper-Session", async () => {
const code = crypto.randomUUID().slice(0, 6);
await storeLinkCode(code, server.id, "route-uuid-2", "MeTester", "online");
const redeemed = (await (await post("/api/link/redeem", { code })).json()) as any;
const res = await get("/api/me");
// no header at all
expect((await res.json() as any).account).toBeNull();
const authed = await app.handle(
new Request("http://localhost/api/me", {
headers: { "X-MCMapper-Session": redeemed.sessionToken },
}),
);
const authedBody = (await authed.json()) as any;
expect(authedBody.account.username).toBe("MeTester");
});
test("POST /api/unlink revokes the session", async () => {
const code = crypto.randomUUID().slice(0, 6);
await storeLinkCode(code, server.id, "route-uuid-3", "UnlinkTester", "online");
const redeemed = (await (await post("/api/link/redeem", { code })).json()) as any;
const unlinkRes = await post("/api/unlink", undefined, { "X-MCMapper-Session": redeemed.sessionToken });
expect(unlinkRes.status).toBe(200);
const afterUnlink = await app.handle(
new Request("http://localhost/api/me", {
headers: { "X-MCMapper-Session": redeemed.sessionToken },
}),
);
expect((await afterUnlink.json() as any).account).toBeNull();
});
});
+29
View File
@@ -3,7 +3,9 @@ import { and, eq } from "drizzle-orm";
import { db } from "./db/client"; import { db } from "./db/client";
import { meshPointers, servers, tilePointers } from "./db/schema"; import { meshPointers, servers, tilePointers } from "./db/schema";
import { wsGateway } from "./ws-gateway"; import { wsGateway } from "./ws-gateway";
import { chatGateway } from "./chat-gateway";
import { minio, TILE_BUCKET, ensureTileBucket } from "./minio"; import { minio, TILE_BUCKET, ensureTileBucket } from "./minio";
import { redeemLinkCode, getAccountForSession, revokeSession } from "./link";
await ensureTileBucket(); await ensureTileBucket();
@@ -87,10 +89,37 @@ export const app = new Elysia()
set.headers["content-type"] = "application/octet-stream"; set.headers["content-type"] = "application/octet-stream";
return new Response(stream as any); return new Response(stream as any);
}) })
// See link.ts's doc comment: the session token comes back in the body, not an httpOnly
// cookie, and is sent back via this header on subsequent requests.
.post("/api/link/redeem", async ({ body, set }) => {
const { code } = body as { code?: string };
if (!code) {
set.status = 400;
return { ok: false, error: "missing_code" };
}
const result = await redeemLinkCode(code);
if (!result.ok) set.status = 400;
return result;
})
.get("/api/me", async ({ headers }) => {
const token = headers["x-mcmapper-session"];
const account = token ? await getAccountForSession(token) : null;
return { account: account ?? null };
})
.post("/api/unlink", async ({ headers }) => {
const token = headers["x-mcmapper-session"];
if (token) await revokeSession(token);
return { ok: true };
})
.ws("/ws", { .ws("/ws", {
open: wsGateway.open, open: wsGateway.open,
message: wsGateway.message, message: wsGateway.message,
close: wsGateway.close, close: wsGateway.close,
})
.ws("/ws/chat/:serverId", {
open: chatGateway.open,
message: chatGateway.message,
close: chatGateway.close,
}); });
// Only bind a real port when run directly (`bun run src/index.ts`) — tests import `app` and // Only bind a real port when run directly (`bun run src/index.ts`) — tests import `app` and
+116
View File
@@ -0,0 +1,116 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { eq } from "drizzle-orm";
import { db } from "./db/client";
import { accounts } from "./db/schema";
import { storeLinkCode, redeemLinkCode, getAccountForSession, revokeSession } from "./link";
import { createTestServer, deleteTestServer } from "./test-helpers";
describe("link code redemption", () => {
let serverA: { id: string };
let serverB: { id: string };
beforeAll(async () => {
serverA = await createTestServer("link-a");
serverB = await createTestServer("link-b");
});
afterAll(async () => {
await deleteTestServer(serverA.id);
await deleteTestServer(serverB.id);
});
test("redeeming an unknown code fails", async () => {
const result = await redeemLinkCode("does-not-exist");
expect(result.ok).toBe(false);
});
test("a stored code redeems into a session and account", async () => {
const code = crypto.randomUUID().slice(0, 6);
await storeLinkCode(code, serverA.id, "uuid-1", "Steve", "online");
const result = await redeemLinkCode(code);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.account.username).toBe("Steve");
expect(result.sessionToken).toBeTruthy();
const resolved = await getAccountForSession(result.sessionToken);
expect(resolved?.id).toBe(result.account.id);
});
test("a code can only be redeemed once", async () => {
const code = crypto.randomUUID().slice(0, 6);
await storeLinkCode(code, serverA.id, "uuid-2", "Alex", "online");
const first = await redeemLinkCode(code);
expect(first.ok).toBe(true);
const second = await redeemLinkCode(code);
expect(second.ok).toBe(false);
});
test("online accounts merge into one global identity across servers", async () => {
const codeA = crypto.randomUUID().slice(0, 6);
await storeLinkCode(codeA, serverA.id, "shared-uuid", "Notch", "online");
const resultA = await redeemLinkCode(codeA);
expect(resultA.ok).toBe(true);
const codeB = crypto.randomUUID().slice(0, 6);
await storeLinkCode(codeB, serverB.id, "shared-uuid", "Notch", "online");
const resultB = await redeemLinkCode(codeB);
expect(resultB.ok).toBe(true);
if (!resultA.ok || !resultB.ok) return;
expect(resultA.account.id).toBe(resultB.account.id);
});
test("offline accounts are scoped per server, not merged", async () => {
const codeA = crypto.randomUUID().slice(0, 6);
await storeLinkCode(codeA, serverA.id, "offline-uuid", "Herobrine", "offline");
const resultA = await redeemLinkCode(codeA);
expect(resultA.ok).toBe(true);
const codeB = crypto.randomUUID().slice(0, 6);
await storeLinkCode(codeB, serverB.id, "offline-uuid", "Herobrine", "offline");
const resultB = await redeemLinkCode(codeB);
expect(resultB.ok).toBe(true);
if (!resultA.ok || !resultB.ok) return;
expect(resultA.account.id).not.toBe(resultB.account.id);
});
test("re-linking updates the stored username", async () => {
const codeA = crypto.randomUUID().slice(0, 6);
await storeLinkCode(codeA, serverA.id, "rename-uuid", "OldName", "online");
const resultA = await redeemLinkCode(codeA);
expect(resultA.ok).toBe(true);
if (!resultA.ok) return;
const codeB = crypto.randomUUID().slice(0, 6);
await storeLinkCode(codeB, serverA.id, "rename-uuid", "NewName", "online");
const resultB = await redeemLinkCode(codeB);
expect(resultB.ok).toBe(true);
if (!resultB.ok) return;
expect(resultB.account.id).toBe(resultA.account.id);
const [row] = await db.select().from(accounts).where(eq(accounts.id, resultA.account.id));
expect(row?.username).toBe("NewName");
});
test("revoking a session invalidates it", async () => {
const code = crypto.randomUUID().slice(0, 6);
await storeLinkCode(code, serverA.id, "uuid-revoke", "Revocable", "online");
const result = await redeemLinkCode(code);
expect(result.ok).toBe(true);
if (!result.ok) return;
await revokeSession(result.sessionToken);
const resolved = await getAccountForSession(result.sessionToken);
expect(resolved).toBeNull();
});
test("an unknown session token resolves to null", async () => {
const resolved = await getAccountForSession("not-a-real-token");
expect(resolved).toBeNull();
});
});
+104
View File
@@ -0,0 +1,104 @@
import { and, eq, isNull } from "drizzle-orm";
import { db } from "./db/client";
import { accounts, sessions } from "./db/schema";
import { redis } from "./redis";
const LINK_CODE_TTL_SECONDS = 600; // 10 minutes, per the plan's "Identity & Linking" section
type AuthMode = "online" | "offline";
interface LinkCodePayload {
serverId: string;
uuid: string;
username: string;
authMode: AuthMode;
}
/**
* The mod generates the code itself (so it can show it to the player immediately, without
* waiting on a backend round-trip) and sends it here to be stored — this just remembers what
* that code means until it's redeemed or expires. See LinkRequest.java's javadoc on the mod
* side and ws-gateway.ts's "link_request" handling.
*/
export async function storeLinkCode(
code: string,
serverId: string,
uuid: string,
username: string,
authMode: AuthMode,
) {
const payload: LinkCodePayload = { serverId, uuid, username, authMode };
await redis.set(`mcmapper:link:${code}`, JSON.stringify(payload), "EX", LINK_CODE_TTL_SECONDS);
}
export type RedeemResult =
| { ok: true; sessionToken: string; account: { id: string; username: string } }
| { ok: false; error: string };
/**
* Redemption is a plain HTTP POST from the browser (see index.ts's /api/link/redeem) — the code
* itself, once looked up, already carries everything needed (server, uuid, username, authMode),
* so the browser doesn't need to be authenticated as anything beforehand.
*
* The returned `sessionToken` is handed back in the response body rather than set as an
* httpOnly cookie — a deliberate Phase 3 MVP simplification (avoids depending on exactly how
* Elysia's `.ws()` routes surface cookies, which wasn't worth resolving for a hobby project's
* first pass) traded for XSS exposure of the token. Worth revisiting once there's an actually
* security-sensitive surface behind a session (e.g. admin actions) — the client is expected to
* hold it in memory/localStorage and send it back via the `X-MCMapper-Session` header.
*/
export async function redeemLinkCode(code: string): Promise<RedeemResult> {
const key = `mcmapper:link:${code}`;
const raw = await redis.get(key);
if (!raw) return { ok: false, error: "invalid_or_expired_code" };
await redis.del(key); // single-use
const payload: LinkCodePayload = JSON.parse(raw);
const account = await upsertAccount(payload);
const sessionToken = `${crypto.randomUUID()}${crypto.randomUUID()}`;
await db.insert(sessions).values({ token: sessionToken, accountId: account.id });
return { ok: true, sessionToken, account: { id: account.id, username: account.username } };
}
async function upsertAccount({ serverId, uuid, username, authMode }: LinkCodePayload) {
// Online accounts merge globally by uuid (server_id IS NULL); offline accounts are scoped to
// the one server that issued the uuid hash — see schema.ts's `accounts` comment and the
// migration's partial unique indexes.
const scopeCondition =
authMode === "online"
? and(eq(accounts.mcUuid, uuid), isNull(accounts.serverId))
: and(eq(accounts.mcUuid, uuid), eq(accounts.serverId, serverId));
const [existing] = await db.select().from(accounts).where(scopeCondition).limit(1);
if (existing) {
const [updated] = await db.update(accounts).set({ username }).where(eq(accounts.id, existing.id)).returning();
return updated!;
}
const [created] = await db
.insert(accounts)
.values({
mcUuid: uuid,
username,
authMode,
serverId: authMode === "online" ? null : serverId,
})
.returning();
return created!;
}
export async function getAccountForSession(token: string) {
const [row] = await db
.select({ id: accounts.id, username: accounts.username })
.from(accounts)
.innerJoin(sessions, eq(sessions.accountId, accounts.id))
.where(eq(sessions.token, token))
.limit(1);
return row ?? null;
}
export async function revokeSession(token: string) {
await db.delete(sessions).where(eq(sessions.token, token));
}
+15 -1
View File
@@ -1,6 +1,6 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "./db/client"; import { db } from "./db/client";
import { servers } from "./db/schema"; import { accounts, servers, sessions } from "./db/schema";
/** /**
* Integration tests need real Postgres/Redis/MinIO — see README's "Running tests" section for * Integration tests need real Postgres/Redis/MinIO — see README's "Running tests" section for
@@ -18,6 +18,20 @@ export async function deleteTestServer(serverId: string) {
await db.delete(servers).where(eq(servers.id, serverId)); await db.delete(servers).where(eq(servers.id, serverId));
} }
/**
* Creates a linked account + session directly (bypassing the link-code redemption flow, which
* has its own dedicated tests in link.test.ts) — for tests that just need *some* valid session.
*/
export async function createTestSession(username: string) {
const [account] = await db
.insert(accounts)
.values({ mcUuid: `test-${crypto.randomUUID()}`, username, authMode: "online" })
.returning();
const sessionToken = `test-session-${crypto.randomUUID()}`;
await db.insert(sessions).values({ token: sessionToken, accountId: account!.id });
return { accountId: account!.id, sessionToken };
}
/** Minimal stand-in for Elysia's WS connection object — enough for wsGateway.message()/close(). */ /** Minimal stand-in for Elysia's WS connection object — enough for wsGateway.message()/close(). */
export class FakeSocket { export class FakeSocket {
id = crypto.randomUUID(); id = crypto.randomUUID();
+61 -2
View File
@@ -1,10 +1,12 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test"; import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { db } from "./db/client"; import { db } from "./db/client";
import { chunkColumns, chunkSections } from "./db/schema"; import { chatMessages, chunkColumns, chunkSections } from "./db/schema";
import { redis, DIRTY_CHUNK_STREAM } from "./redis"; import { redis, DIRTY_CHUNK_STREAM } from "./redis";
import { wsGateway, chunkOf } from "./ws-gateway"; import { wsGateway, chunkOf, getModSocket } from "./ws-gateway";
import { createTestServer, deleteTestServer, FakeSocket } from "./test-helpers"; import { createTestServer, deleteTestServer, FakeSocket } from "./test-helpers";
import { redeemLinkCode } from "./link";
import { chatChannel } from "./chat";
describe("chunkOf", () => { describe("chunkOf", () => {
test("floors toward negative infinity, matching Math.floor(coord/16)", () => { test("floors toward negative infinity, matching Math.floor(coord/16)", () => {
@@ -160,4 +162,61 @@ describe("wsGateway.message", () => {
await wsGateway.message(socket2, JSON.stringify({ type: "columns", dimension: 0, columns: [] })); await wsGateway.message(socket2, JSON.stringify({ type: "columns", dimension: 0, columns: [] }));
expect(socket2.lastMessage()).toEqual({ type: "error", error: "not_authenticated" }); expect(socket2.lastMessage()).toEqual({ type: "error", error: "not_authenticated" });
}); });
test("link_request stores a redeemable code carrying the mod-supplied identity", async () => {
const socket = new FakeSocket();
await wsGateway.message(socket, JSON.stringify({ type: "hello", token: server.token }));
const code = crypto.randomUUID().slice(0, 6);
await wsGateway.message(
socket,
JSON.stringify({
type: "link_request",
code,
uuid: "mod-supplied-uuid",
username: "ModPlayer",
authMode: "online",
}),
);
const redeemed = await redeemLinkCode(code);
expect(redeemed.ok).toBe(true);
if (!redeemed.ok) return;
expect(redeemed.account.username).toBe("ModPlayer");
});
test("getModSocket returns the authenticated connection for a server, and forgets it on close", async () => {
const socket = new FakeSocket();
await wsGateway.message(socket, JSON.stringify({ type: "hello", token: server.token }));
expect(getModSocket(server.id)).toBe(socket);
wsGateway.close(socket);
expect(getModSocket(server.id)).toBeUndefined();
});
test("chat from the mod persists it and publishes it for web viewers", async () => {
const socket = new FakeSocket();
await wsGateway.message(socket, JSON.stringify({ type: "hello", token: server.token }));
const sub = redis.duplicate();
await sub.subscribe(chatChannel(server.id));
const received = new Promise<string>((resolve) => {
sub.once("message", (_channel, message) => resolve(message));
});
await wsGateway.message(
socket,
JSON.stringify({ type: "chat", uuid: "chatter-uuid", username: "InGamePlayer", message: "gg" }),
);
expect(JSON.parse(await received)).toEqual({ username: "InGamePlayer", message: "gg", source: "game" });
sub.disconnect();
const rows = await db
.select()
.from(chatMessages)
.where(and(eq(chatMessages.serverId, server.id), eq(chatMessages.message, "gg")));
expect(rows).toHaveLength(1);
expect(rows[0]!.source).toBe("game");
});
}); });
+44
View File
@@ -2,6 +2,8 @@ import { eq } from "drizzle-orm";
import { db } from "./db/client"; import { db } from "./db/client";
import { chunkColumns, chunkSections, servers } from "./db/schema"; import { chunkColumns, chunkSections, servers } from "./db/schema";
import { markChunkDirty } from "./redis"; import { markChunkDirty } from "./redis";
import { storeLinkCode } from "./link";
import { recordAndPublishChat } from "./chat";
// Wire protocol (mod <-> api), one JSON object per WS text frame: // Wire protocol (mod <-> api), one JSON object per WS text frame:
// //
@@ -25,6 +27,20 @@ import { markChunkDirty } from "./redis";
// blockStateIds, base64-encoded, indexed by `(ly*16 + lz)*16 + lx` within the section. // blockStateIds, base64-encoded, indexed by `(ly*16 + lz)*16 + lx` within the section.
// A "sections" message marks the chunk dirty the same way "columns" does — one dirty-chunk // A "sections" message marks the chunk dirty the same way "columns" does — one dirty-chunk
// event now triggers the worker to re-render both the 2D tile and any 3D meshes for that chunk. // event now triggers the worker to re-render both the 2D tile and any 3D meshes for that chunk.
//
// mod -> api {"type":"link_request","code":"AB3XQ9","uuid":"...","username":"...","authMode":"online"}
//
// Phase 3: sent when a player runs `/mcmapper link` — see LinkRequest.java on the mod side and
// link.ts's storeLinkCode/redeemLinkCode for the rest of the flow (redemption happens over plain
// HTTP from the browser, not over this WS connection — see index.ts's /api/link/redeem).
//
// mod -> api {"type":"chat","uuid":"...","username":"...","message":"..."}
// api -> mod {"type":"chat","username":"...","message":"..."}
//
// In-game chat, both directions. mod -> api is this module (records + publishes for web
// viewers, see chat.ts); api -> mod is sent by the browser-facing /ws/chat route (chat-gateway.ts)
// looking up the mod's connection via getModSocket() and calling ChatBridge.injectWebChatMessage
// on the mod side.
interface Column { interface Column {
x: number; x: number;
@@ -44,11 +60,18 @@ interface ConnState {
} }
const connections = new Map<string | number, ConnState>(); const connections = new Map<string | number, ConnState>();
// Reverse lookup for chat-gateway.ts to forward web-originated chat into the right mod
// connection — populated on a successful hello, cleaned up on close (see wsGateway.close()).
const modSocketsByServer = new Map<string, any>();
export function chunkOf(coord: number): number { export function chunkOf(coord: number): number {
return Math.floor(coord / 16); return Math.floor(coord / 16);
} }
export function getModSocket(serverId: string) {
return modSocketsByServer.get(serverId);
}
export const wsGateway = { export const wsGateway = {
async open() { async open() {
// Nothing to do until `hello` arrives — see message(). // Nothing to do until `hello` arrives — see message().
@@ -73,6 +96,7 @@ export const wsGateway = {
return; return;
} }
connections.set(ws.id, { serverId: row.id }); connections.set(ws.id, { serverId: row.id });
modSocketsByServer.set(row.id, ws);
ws.send(JSON.stringify({ type: "hello_ack", ok: true, serverId: row.id })); ws.send(JSON.stringify({ type: "hello_ack", ok: true, serverId: row.id }));
console.log(`[ws] server '${row.name}' (${row.id}) authenticated`); console.log(`[ws] server '${row.name}' (${row.id}) authenticated`);
return; return;
@@ -154,9 +178,29 @@ export const wsGateway = {
await markChunkDirty(state.serverId, dimension, chunkX, chunkZ); await markChunkDirty(state.serverId, dimension, chunkX, chunkZ);
return; return;
} }
if (msg.type === "link_request") {
// The mod generates `code` itself (so it can show it to the player immediately) — this
// just remembers what it means until redeemed. See link.ts's storeLinkCode doc comment.
await storeLinkCode(msg.code, state.serverId, msg.uuid, msg.username, msg.authMode);
return;
}
if (msg.type === "chat") {
await recordAndPublishChat(state.serverId, {
username: msg.username,
message: msg.message,
source: "game",
});
return;
}
}, },
close(ws: any) { close(ws: any) {
const state = connections.get(ws.id);
if (state && modSocketsByServer.get(state.serverId) === ws) {
modSocketsByServer.delete(state.serverId);
}
connections.delete(ws.id); connections.delete(ws.id);
}, },
}; };
+92 -1
View File
@@ -1,4 +1,5 @@
// Barebones Leaflet viewer (Phase 1). No auth/marker/chat UI yet — those are Phase 3/4. // Barebones Leaflet viewer (Phase 1) + chat/linking (Phase 3). Marker tool and admin panel are
// still later phases.
// //
// Tiles are one Minecraft chunk (16x16 blocks) each, upscaled to 256px, at a single native // Tiles are one Minecraft chunk (16x16 blocks) each, upscaled to 256px, at a single native
// zoom level (see api's tile route + worker/src/render/cpu.rs) — Leaflet stretches that one // zoom level (see api's tile route + worker/src/render/cpu.rs) — Leaflet stretches that one
@@ -8,12 +9,28 @@
// on screen. Minecraft's Z grows south (visually "down" on a conventional north-up map), so // on screen. Minecraft's Z grows south (visually "down" on a conventional north-up map), so
// tile y = -chunkZ here; the api negates it back to chunkZ when looking up the tile pointer // tile y = -chunkZ here; the api negates it back to chunkZ when looking up the tile pointer
// (see api/src/index.ts's /api/tiles route comment). // (see api/src/index.ts's /api/tiles route comment).
//
// Session handling: the session token from /api/link/redeem is kept in localStorage and sent
// back via the X-MCMapper-Session header / a WS message field, not an httpOnly cookie — see
// api/src/link.ts's doc comment for why that's a deliberate Phase 3 MVP tradeoff.
const SESSION_STORAGE_KEY = "mcmapper_session";
const NICKNAME_STORAGE_KEY = "mcmapper_nickname";
function mapmapper() { function mapmapper() {
return { return {
loading: true, loading: true,
server: null, server: null,
leaflet: null, leaflet: null,
chatSocket: null,
chatMessages: [],
chatInput: "",
linkCode: "",
linkStatus: "",
sessionToken: localStorage.getItem(SESSION_STORAGE_KEY) || null,
account: null,
nickname: localStorage.getItem(NICKNAME_STORAGE_KEY) || "",
async init() { async init() {
const servers = await fetch("/api/servers").then((r) => r.json()); const servers = await fetch("/api/servers").then((r) => r.json());
this.loading = false; this.loading = false;
@@ -32,7 +49,81 @@ function mapmapper() {
"data:image/svg+xml;base64," + "data:image/svg+xml;base64," +
btoa('<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256"><rect width="256" height="256" fill="#1e1e28"/></svg>'), btoa('<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256"><rect width="256" height="256" fill="#1e1e28"/></svg>'),
}).addTo(this.leaflet); }).addTo(this.leaflet);
await this.loadAccount();
this.connectChat();
} }
}, },
async loadAccount() {
if (!this.sessionToken) return;
const res = await fetch("/api/me", { headers: { "X-MCMapper-Session": this.sessionToken } });
const { account } = await res.json();
this.account = account;
// A revoked/expired session should stop being sent as if it were still valid.
if (!account) {
this.sessionToken = null;
localStorage.removeItem(SESSION_STORAGE_KEY);
}
},
connectChat() {
const proto = location.protocol === "https:" ? "wss" : "ws";
this.chatSocket = new WebSocket(`${proto}://${location.host}/ws/chat/${this.server.id}`);
this.chatSocket.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === "error") {
this.linkStatus = msg.error;
return;
}
this.chatMessages.push({ id: crypto.randomUUID(), ...msg });
if (this.chatMessages.length > 200) this.chatMessages.shift();
this.$nextTick(() => {
this.$refs.chatLog.scrollTop = this.$refs.chatLog.scrollHeight;
});
};
},
sendChat() {
const message = this.chatInput.trim();
if (!message || !this.chatSocket) return;
this.chatSocket.send(
JSON.stringify({ type: "chat", message, sessionToken: this.sessionToken, nickname: this.nickname }),
);
this.chatInput = "";
},
saveNickname() {
localStorage.setItem(NICKNAME_STORAGE_KEY, this.nickname);
},
async redeemLink() {
const code = this.linkCode.trim();
if (!code) return;
const res = await fetch("/api/link/redeem", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ code }),
});
const data = await res.json();
if (data.ok) {
this.sessionToken = data.sessionToken;
localStorage.setItem(SESSION_STORAGE_KEY, this.sessionToken);
this.account = data.account;
this.linkStatus = `linked as ${data.account.username}`;
this.linkCode = "";
} else {
this.linkStatus = data.error;
}
},
async unlink() {
if (this.sessionToken) {
await fetch("/api/unlink", { method: "POST", headers: { "X-MCMapper-Session": this.sessionToken } });
}
localStorage.removeItem(SESSION_STORAGE_KEY);
this.sessionToken = null;
this.account = null;
},
}; };
} }
+36
View File
@@ -20,5 +20,41 @@ html(lang="en")
span(x-text="server?.name") span(x-text="server?.name")
span.text-sm.text-neutral-500(x-show="loading") loading servers… span.text-sm.text-neutral-500(x-show="loading") loading servers…
span.text-sm.text-red-400(x-show="!loading && !server") No server registered yet — see backend README (bun run seed). span.text-sm.text-red-400(x-show="!loading && !server") No server registered yet — see backend README (bun run seed).
div.flex-1.flex.overflow-hidden
div#map.flex-1 div#map.flex-1
aside.w-80.flex.flex-col.border-l.border-neutral-700.bg-neutral-800(x-show="server")
div.flex-1.overflow-y-auto.p-2.space-y-1(x-ref="chatLog")
template(x-for="msg in chatMessages" x-bind:key="msg.id")
p.text-sm.break-words
span.font-semibold(x-text="msg.username")
span.text-neutral-500.text-xs(x-show="msg.source === 'game'") &nbsp;[game]
span.text-neutral-400 :&nbsp;
span(x-text="msg.message")
div.p-2.border-t.border-neutral-700.space-y-2
template(x-if="account")
div.text-xs.text-neutral-400.flex.items-center.gap-2
span
| Linked as
span.font-semibold.text-neutral-200(x-text="' ' + account.username")
button.underline(x-on:click="unlink") unlink
template(x-if="!account")
input.w-full.bg-neutral-900.text-sm.px-2.py-1.rounded.border.border-neutral-700(
type="text" placeholder="nickname (optional)" x-model="nickname"
x-on:change="saveNickname")
div.flex.gap-1
input.flex-1.bg-neutral-900.text-sm.px-2.py-1.rounded.border.border-neutral-700(
type="text" placeholder="message" x-model="chatInput"
x-on:keydown.enter="sendChat")
button.px-2.py-1.bg-neutral-700.rounded.text-sm(x-on:click="sendChat") Send
template(x-if="!account")
div.flex.gap-1
input.flex-1.bg-neutral-900.text-sm.px-2.py-1.rounded.border.border-neutral-700(
type="text" placeholder="link code from /mcmapper link" x-model="linkCode")
button.px-2.py-1.bg-neutral-700.rounded.text-sm(x-on:click="redeemLink") Link
p.text-xs.text-amber-400(x-show="linkStatus" x-text="linkStatus")
script(src="/js/map.js") script(src="/js/map.js")