From 6770bc23cc9a62fc615a51e97afe979baf7a1bf4 Mon Sep 17 00:00:00 2001 From: Octoturge Date: Sat, 8 Aug 2026 17:08:23 +0200 Subject: [PATCH] Phase 3: account linking and two-way chat relay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- api/drizzle/0002_linking_and_chat.sql | 32 +++++++ api/src/chat-gateway.ts | 77 +++++++++++++++++ api/src/chat.test.ts | 117 ++++++++++++++++++++++++++ api/src/chat.ts | 61 ++++++++++++++ api/src/db/schema.ts | 46 +++++++++- api/src/index.test.ts | 76 +++++++++++++++++ api/src/index.ts | 29 +++++++ api/src/link.test.ts | 116 +++++++++++++++++++++++++ api/src/link.ts | 104 +++++++++++++++++++++++ api/src/test-helpers.ts | 16 +++- api/src/ws-gateway.test.ts | 63 +++++++++++++- api/src/ws-gateway.ts | 44 ++++++++++ frontend/src/public/js/map.js | 93 +++++++++++++++++++- frontend/src/views/index.pug | 38 ++++++++- 14 files changed, 906 insertions(+), 6 deletions(-) create mode 100644 api/drizzle/0002_linking_and_chat.sql create mode 100644 api/src/chat-gateway.ts create mode 100644 api/src/chat.test.ts create mode 100644 api/src/chat.ts create mode 100644 api/src/link.test.ts create mode 100644 api/src/link.ts diff --git a/api/drizzle/0002_linking_and_chat.sql b/api/drizzle/0002_linking_and_chat.sql new file mode 100644 index 0000000..b25a975 --- /dev/null +++ b/api/drizzle/0002_linking_and_chat.sql @@ -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"); diff --git a/api/src/chat-gateway.ts b/api/src/chat-gateway.ts new file mode 100644 index 0000000..28c2212 --- /dev/null +++ b/api/src/chat-gateway.ts @@ -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>(); + +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 }; diff --git a/api/src/chat.test.ts b/api/src/chat.test.ts new file mode 100644 index 0000000..c36600b --- /dev/null +++ b/api/src/chat.test.ts @@ -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((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" }); + }); +}); diff --git a/api/src/chat.ts b/api/src/chat.ts new file mode 100644 index 0000000..4433510 --- /dev/null +++ b/api/src/chat.ts @@ -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 { + 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 { + 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" }; +} diff --git a/api/src/db/schema.ts b/api/src/db/schema.ts index 060c4ef..13c8956 100644 --- a/api/src/db/schema.ts +++ b/api/src/db/schema.ts @@ -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) — // 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(), token: text("token").notNull().unique(), 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(), }); diff --git a/api/src/index.test.ts b/api/src/index.test.ts index b1bb5b7..e1eb4c5 100644 --- a/api/src/index.test.ts +++ b/api/src/index.test.ts @@ -4,6 +4,7 @@ import { db } from "./db/client"; import { meshPointers, tilePointers } from "./db/schema"; import { minio, TILE_BUCKET } from "./minio"; import { createTestServer, deleteTestServer } from "./test-helpers"; +import { storeLinkCode } from "./link"; // 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 @@ -12,6 +13,16 @@ function get(path: string) { return app.handle(new Request(`http://localhost${path}`)); } +function post(path: string, body?: unknown, headers?: Record) { + 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", () => { test("reports ok", async () => { const res = await get("/health"); @@ -126,3 +137,68 @@ describe("GET /api/meshes/...", () => { 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(); + }); +}); diff --git a/api/src/index.ts b/api/src/index.ts index 45e0f50..464a717 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -3,7 +3,9 @@ import { and, eq } from "drizzle-orm"; import { db } from "./db/client"; import { meshPointers, servers, tilePointers } from "./db/schema"; import { wsGateway } from "./ws-gateway"; +import { chatGateway } from "./chat-gateway"; import { minio, TILE_BUCKET, ensureTileBucket } from "./minio"; +import { redeemLinkCode, getAccountForSession, revokeSession } from "./link"; await ensureTileBucket(); @@ -87,10 +89,37 @@ export const app = new Elysia() set.headers["content-type"] = "application/octet-stream"; 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", { open: wsGateway.open, message: wsGateway.message, 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 diff --git a/api/src/link.test.ts b/api/src/link.test.ts new file mode 100644 index 0000000..eb8df3a --- /dev/null +++ b/api/src/link.test.ts @@ -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(); + }); +}); diff --git a/api/src/link.ts b/api/src/link.ts new file mode 100644 index 0000000..8aceae0 --- /dev/null +++ b/api/src/link.ts @@ -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 { + 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)); +} diff --git a/api/src/test-helpers.ts b/api/src/test-helpers.ts index 65a15f6..e4f3769 100644 --- a/api/src/test-helpers.ts +++ b/api/src/test-helpers.ts @@ -1,6 +1,6 @@ import { eq } from "drizzle-orm"; 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 @@ -18,6 +18,20 @@ export async function deleteTestServer(serverId: string) { 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(). */ export class FakeSocket { id = crypto.randomUUID(); diff --git a/api/src/ws-gateway.test.ts b/api/src/ws-gateway.test.ts index cc007d4..5c61902 100644 --- a/api/src/ws-gateway.test.ts +++ b/api/src/ws-gateway.test.ts @@ -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((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"); + }); }); diff --git a/api/src/ws-gateway.ts b/api/src/ws-gateway.ts index 0f24207..2353c10 100644 --- a/api/src/ws-gateway.ts +++ b/api/src/ws-gateway.ts @@ -2,6 +2,8 @@ import { eq } from "drizzle-orm"; import { db } from "./db/client"; import { chunkColumns, chunkSections, servers } from "./db/schema"; import { markChunkDirty } from "./redis"; +import { storeLinkCode } from "./link"; +import { recordAndPublishChat } from "./chat"; // 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. // 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. +// +// 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 { x: number; @@ -44,11 +60,18 @@ interface ConnState { } const connections = new Map(); +// 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(); export function chunkOf(coord: number): number { return Math.floor(coord / 16); } +export function getModSocket(serverId: string) { + return modSocketsByServer.get(serverId); +} + export const wsGateway = { async open() { // Nothing to do until `hello` arrives — see message(). @@ -73,6 +96,7 @@ export const wsGateway = { return; } connections.set(ws.id, { serverId: row.id }); + modSocketsByServer.set(row.id, ws); ws.send(JSON.stringify({ type: "hello_ack", ok: true, serverId: row.id })); console.log(`[ws] server '${row.name}' (${row.id}) authenticated`); return; @@ -154,9 +178,29 @@ export const wsGateway = { await markChunkDirty(state.serverId, dimension, chunkX, chunkZ); 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) { + const state = connections.get(ws.id); + if (state && modSocketsByServer.get(state.serverId) === ws) { + modSocketsByServer.delete(state.serverId); + } connections.delete(ws.id); }, }; diff --git a/frontend/src/public/js/map.js b/frontend/src/public/js/map.js index f82726d..10e389b 100644 --- a/frontend/src/public/js/map.js +++ b/frontend/src/public/js/map.js @@ -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 // 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 // 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). +// +// 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() { return { loading: true, server: 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() { const servers = await fetch("/api/servers").then((r) => r.json()); this.loading = false; @@ -32,7 +49,81 @@ function mapmapper() { "data:image/svg+xml;base64," + btoa(''), }).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; + }, }; } diff --git a/frontend/src/views/index.pug b/frontend/src/views/index.pug index db069ba..d7fbbef 100644 --- a/frontend/src/views/index.pug +++ b/frontend/src/views/index.pug @@ -20,5 +20,41 @@ html(lang="en") span(x-text="server?.name") 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). - div#map.flex-1 + div.flex-1.flex.overflow-hidden + 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'")  [game] + span.text-neutral-400 :  + 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")