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:
@@ -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");
|
||||
@@ -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<string, Set<any>>();
|
||||
|
||||
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 };
|
||||
@@ -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<string>((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" });
|
||||
});
|
||||
});
|
||||
@@ -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<boolean> {
|
||||
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<ChatIdentity> {
|
||||
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" };
|
||||
}
|
||||
+45
-1
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, string>) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+104
@@ -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<RedeemResult> {
|
||||
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));
|
||||
}
|
||||
+15
-1
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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