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