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:
@@ -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<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 {
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user