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
+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
// 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('<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256"><rect width="256" height="256" fill="#1e1e28"/></svg>'),
}).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;
},
};
}