Files
MCMapper-Backend/api/src/db/schema.ts
T
octoturge 6770bc23cc 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).
2026-08-08 17:08:23 +02:00

145 lines
6.8 KiB
TypeScript

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.
export const servers = pgTable("servers", {
id: uuid("id").defaultRandom().primaryKey(),
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(),
});
// Column-granularity world state: the topmost non-air block per (dimension, x, z), plus its
// height. Kept deliberately separate from `chunkSections` below — cheap to write/read for 2D
// tile rendering, which never needs full voxel data.
export const chunkColumns = pgTable(
"chunk_columns",
{
serverId: uuid("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
dimension: integer("dimension").notNull(),
x: integer("x").notNull(),
z: integer("z").notNull(),
blockId: integer("block_id").notNull(),
blockMeta: integer("block_meta").notNull(),
height: smallint("height").notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [primaryKey({ columns: [table.serverId, table.dimension, table.x, table.z] })],
);
// Full-voxel storage for one 16x16x16 section (sectionY = worldY / 16), for 3D meshing —
// additive to `chunkColumns`, not a replacement (see that table's comment). `blocks` is the
// same base64 the mod sends over the wire: 4096 little-endian u16 blockStateIds, indexed by
// `(ly*16 + lz)*16 + lx` within the section. Stored as base64 text rather than real bytea to
// avoid postgres-js/drizzle binary-column plumbing for what's still an MVP — worth revisiting
// if storage size ever matters (base64 is ~33% larger than raw bytes).
export const chunkSections = pgTable(
"chunk_sections",
{
serverId: uuid("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
dimension: integer("dimension").notNull(),
x: integer("x").notNull(),
z: integer("z").notNull(),
sectionY: integer("section_y").notNull(),
blocks: text("blocks").notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
primaryKey({ columns: [table.serverId, table.dimension, table.x, table.z, table.sectionY] }),
],
);
// Metadata pointer to a rendered mesh buffer in MinIO, mirroring `tilePointers` but for 3D
// meshes — one row per rendered section (a chunk with N non-empty sections gets N mesh rows,
// each loaded as its own Babylon mesh; see worker/src/mesh/mod.rs for why section boundaries
// aren't merged in Phase 2).
export const meshPointers = pgTable(
"mesh_pointers",
{
serverId: uuid("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
dimension: integer("dimension").notNull(),
x: integer("x").notNull(),
z: integer("z").notNull(),
sectionY: integer("section_y").notNull(),
storageKey: text("storage_key").notNull(),
contentHash: text("content_hash").notNull(),
renderedAt: timestamp("rendered_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
primaryKey({ columns: [table.serverId, table.dimension, table.x, table.z, table.sectionY] }),
],
);
// Metadata pointer to a rendered tile PNG in MinIO — the binary itself never touches Postgres.
// One row per (server, dimension, zoom, tileX, tileZ); zoom is always 0 until Phase 2 adds
// multi-resolution tiles.
export const tilePointers = pgTable(
"tile_pointers",
{
serverId: uuid("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
dimension: integer("dimension").notNull(),
zoom: integer("zoom").notNull().default(0),
tileX: integer("tile_x").notNull(),
tileZ: integer("tile_z").notNull(),
storageKey: text("storage_key").notNull(),
contentHash: text("content_hash").notNull(),
renderedAt: timestamp("rendered_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
primaryKey({ columns: [table.serverId, table.dimension, table.zoom, table.tileX, table.tileZ] }),
],
);