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