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
+61 -2
View File
@@ -1,10 +1,12 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { and, eq } from "drizzle-orm";
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 { wsGateway, chunkOf } from "./ws-gateway";
import { wsGateway, chunkOf, getModSocket } from "./ws-gateway";
import { createTestServer, deleteTestServer, FakeSocket } from "./test-helpers";
import { redeemLinkCode } from "./link";
import { chatChannel } from "./chat";
describe("chunkOf", () => {
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: [] }));
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");
});
});