Add Phase 6: multi-server admin panel (server registry + settings)

Gate a new /api/admin/* route set (register/list/update/delete servers)
behind a single shared MCMAPPER_ADMIN_TOKEN header, and add a /admin
frontend page (Alpine) to unlock, register new servers, and edit
authMode/anonymousChatAllowed/waypointFormat per server — these columns
already existed but were only editable via direct DB edit until now.

Written test-first per the project's TDD workflow: admin.ts's domain
logic, the index.ts route wiring, and a new e2e/tests/admin.spec.ts
covering the token gate and register/edit/delete round trip through the
real browser UI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tKdPZt78zbPUZMXWzKEKt
This commit is contained in:
2026-08-09 13:06:37 +02:00
parent 7b85f4dff1
commit a134c47152
13 changed files with 634 additions and 16 deletions
+9 -2
View File
@@ -15,7 +15,14 @@ MINIO_USE_SSL=false
MINIO_ACCESS_KEY=mcmapper
MINIO_SECRET_KEY=changeme-set-in-untracked-env
# Only read by `bun run seed` (see scripts/seed-server.ts) — Phase 6 replaces this with a real
# admin registration API. Point the mod's MapperConfig#serverToken at the same value.
# Only read by `bun run seed` (see scripts/seed-server.ts) — the admin panel's "register a new
# server" form (MCMAPPER_ADMIN_TOKEN below) is now the real registration path; this script is
# kept around as a fallback for scripted/headless setup.
MCMAPPER_SEED_SERVER_NAME=dev-server
MCMAPPER_SEED_SERVER_TOKEN=changeme
# Gates every /api/admin/* route (server registry + settings — see api/src/admin.ts) behind a
# single shared secret, checked via the X-MCMapper-Admin-Token header. Left unset here on
# purpose: an unset token disables admin routes entirely (401) rather than defaulting to open.
# Set a real value in an untracked `.env` next to this file, same as MINIO_SECRET_KEY above.
MCMAPPER_ADMIN_TOKEN=
+131
View File
@@ -0,0 +1,131 @@
import { describe, test, expect, afterEach } from "bun:test";
import { isAdminTokenValid, listServers, registerServer, updateServerSettings, deleteServer } from "./admin";
import { deleteTestServer } from "./test-helpers";
describe("isAdminTokenValid", () => {
const original = process.env.MCMAPPER_ADMIN_TOKEN;
afterEach(() => {
if (original === undefined) delete process.env.MCMAPPER_ADMIN_TOKEN;
else process.env.MCMAPPER_ADMIN_TOKEN = original;
});
test("rejects everything when MCMAPPER_ADMIN_TOKEN is unset — admin routes are disabled by default, not open", () => {
delete process.env.MCMAPPER_ADMIN_TOKEN;
expect(isAdminTokenValid("anything")).toBe(false);
expect(isAdminTokenValid(undefined)).toBe(false);
});
test("rejects a wrong token", () => {
process.env.MCMAPPER_ADMIN_TOKEN = "correct-token";
expect(isAdminTokenValid("wrong-token")).toBe(false);
expect(isAdminTokenValid(undefined)).toBe(false);
});
test("accepts the configured token", () => {
process.env.MCMAPPER_ADMIN_TOKEN = "correct-token";
expect(isAdminTokenValid("correct-token")).toBe(true);
});
});
describe("registerServer", () => {
let createdId: string | undefined;
afterEach(async () => {
if (createdId) await deleteTestServer(createdId);
createdId = undefined;
});
test("creates a server with a generated token", async () => {
const result = await registerServer("admin-test-server", "offline");
expect(result.ok).toBe(true);
if (!result.ok) return;
createdId = result.server.id;
expect(result.server.name).toBe("admin-test-server");
expect(result.server.authMode).toBe("offline");
expect(result.server.token.length).toBeGreaterThan(10);
});
test("rejects an empty name", async () => {
const result = await registerServer(" ", "offline");
expect(result).toEqual({ ok: false, error: "missing_name" });
});
test("rejects an invalid authMode", async () => {
const result = await registerServer("admin-test-bad-mode", "not-a-mode");
expect(result).toEqual({ ok: false, error: "invalid_auth_mode" });
});
});
describe("listServers", () => {
let createdId: string | undefined;
afterEach(async () => {
if (createdId) await deleteTestServer(createdId);
createdId = undefined;
});
test("includes newly registered servers, with their token visible (admin-only view)", async () => {
const created = await registerServer("admin-test-list", "offline");
if (!created.ok) throw new Error("setup failed");
createdId = created.server.id;
const servers = await listServers();
const found = servers.find((s) => s.id === createdId);
expect(found).toBeDefined();
expect(found!.token).toBe(created.server.token);
});
});
describe("updateServerSettings", () => {
let createdId: string | undefined;
afterEach(async () => {
if (createdId) await deleteTestServer(createdId);
createdId = undefined;
});
test("updates the given fields and leaves the rest untouched", async () => {
const created = await registerServer("admin-test-update", "offline");
if (!created.ok) throw new Error("setup failed");
createdId = created.server.id;
const result = await updateServerSettings(createdId, {
anonymousChatAllowed: false,
waypointFormat: "xaero",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.server.name).toBe("admin-test-update"); // untouched
expect(result.server.anonymousChatAllowed).toBe(false);
expect(result.server.waypointFormat).toBe("xaero");
});
test("rejects an invalid waypointFormat", async () => {
const created = await registerServer("admin-test-bad-waypoint", "offline");
if (!created.ok) throw new Error("setup failed");
createdId = created.server.id;
const result = await updateServerSettings(createdId, { waypointFormat: "not-a-format" });
expect(result).toEqual({ ok: false, error: "invalid_waypoint_format" });
});
test("returns not_found for a nonexistent server", async () => {
const result = await updateServerSettings(crypto.randomUUID(), { name: "x" });
expect(result).toEqual({ ok: false, error: "not_found" });
});
});
describe("deleteServer", () => {
test("removes the server row", async () => {
const created = await registerServer("admin-test-delete", "offline");
if (!created.ok) throw new Error("setup failed");
const result = await deleteServer(created.server.id);
expect(result.ok).toBe(true);
const servers = await listServers();
expect(servers.find((s) => s.id === created.server.id)).toBeUndefined();
});
test("returns ok:false for a nonexistent server", async () => {
const result = await deleteServer(crypto.randomUUID());
expect(result.ok).toBe(false);
});
});
+73
View File
@@ -0,0 +1,73 @@
import { eq } from "drizzle-orm";
import { db } from "./db/client";
import { servers } from "./db/schema";
const VALID_AUTH_MODES = ["online", "offline"];
const VALID_WAYPOINT_FORMATS = ["journeymap", "xaero", "off"];
/**
* Admin routes are gated on a single shared secret (`MCMAPPER_ADMIN_TOKEN`), not a per-account
* role — this is a single-operator hobby-project backend, and every other auth concern in this
* codebase already avoids cookies in favor of a bearer-style header (see link.ts's doc comment),
* so this follows the same shape. Unset means disabled, not "open" — there's no sensible default
* admin token to fall back to.
*/
export function isAdminTokenValid(token: string | undefined | null): boolean {
const expected = process.env.MCMAPPER_ADMIN_TOKEN;
if (!expected) return false;
return token === expected;
}
export async function listServers() {
return db.select().from(servers);
}
export type RegisterServerResult =
| { ok: true; server: typeof servers.$inferSelect }
| { ok: false; error: string };
/**
* Replaces the Phase 1 `bun run seed` env-var flow (scripts/seed-server.ts) as the real
* registration path — the token is generated here, never admin-supplied, so it can't collide
* with or be guessed from anything else.
*/
export async function registerServer(name: string, authMode: string): Promise<RegisterServerResult> {
if (!name.trim()) return { ok: false, error: "missing_name" };
if (!VALID_AUTH_MODES.includes(authMode)) return { ok: false, error: "invalid_auth_mode" };
const token = `mcmapper-${crypto.randomUUID()}`;
const [server] = await db.insert(servers).values({ name: name.trim(), authMode, token }).returning();
return { ok: true, server: server! };
}
export type UpdateServerSettingsInput = Partial<{
name: string;
authMode: string;
anonymousChatAllowed: boolean;
waypointFormat: string;
}>;
export type UpdateServerSettingsResult =
| { ok: true; server: typeof servers.$inferSelect }
| { ok: false; error: string };
export async function updateServerSettings(
id: string,
input: UpdateServerSettingsInput,
): Promise<UpdateServerSettingsResult> {
if (input.authMode !== undefined && !VALID_AUTH_MODES.includes(input.authMode)) {
return { ok: false, error: "invalid_auth_mode" };
}
if (input.waypointFormat !== undefined && !VALID_WAYPOINT_FORMATS.includes(input.waypointFormat)) {
return { ok: false, error: "invalid_waypoint_format" };
}
const [server] = await db.update(servers).set(input).where(eq(servers.id, id)).returning();
if (!server) return { ok: false, error: "not_found" };
return { ok: true, server };
}
export async function deleteServer(id: string): Promise<{ ok: boolean }> {
const deleted = await db.delete(servers).where(eq(servers.id, id)).returning({ id: servers.id });
return { ok: deleted.length > 0 };
}
+86 -1
View File
@@ -1,4 +1,4 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { describe, test, expect, beforeAll, afterAll, afterEach } from "bun:test";
import { app } from "./index";
import { db } from "./db/client";
import { meshPointers, tilePointers } from "./db/schema";
@@ -350,3 +350,88 @@ describe("GET /api/export/:serverId/:dimension/:x1/:z1/:x2/:z2", () => {
expect((await res.json()) as any).toEqual({ ok: false, error: "selection_too_large" });
});
});
describe("/api/admin/servers", () => {
const ADMIN_TOKEN = "test-admin-token";
let previousAdminToken: string | undefined;
let createdId: string | undefined;
beforeAll(() => {
previousAdminToken = process.env.MCMAPPER_ADMIN_TOKEN;
process.env.MCMAPPER_ADMIN_TOKEN = ADMIN_TOKEN;
});
afterAll(() => {
if (previousAdminToken === undefined) delete process.env.MCMAPPER_ADMIN_TOKEN;
else process.env.MCMAPPER_ADMIN_TOKEN = previousAdminToken;
});
afterEach(async () => {
if (createdId) {
await deleteTestServer(createdId);
createdId = undefined;
}
});
test("GET rejects a missing or wrong admin token", async () => {
const noHeader = await get("/api/admin/servers");
expect(noHeader.status).toBe(401);
const wrongHeader = await app.handle(
new Request("http://localhost/api/admin/servers", { headers: { "X-MCMapper-Admin-Token": "nope" } }),
);
expect(wrongHeader.status).toBe(401);
});
test("POST registers a server and returns its token; GET then lists it", async () => {
const registerRes = await post(
"/api/admin/servers",
{ name: "admin-route-test", authMode: "offline" },
{ "X-MCMapper-Admin-Token": ADMIN_TOKEN },
);
expect(registerRes.status).toBe(200);
const registerBody = (await registerRes.json()) as any;
expect(registerBody.ok).toBe(true);
expect(registerBody.server.token).toBeTruthy();
createdId = registerBody.server.id;
const listRes = await app.handle(
new Request("http://localhost/api/admin/servers", { headers: { "X-MCMapper-Admin-Token": ADMIN_TOKEN } }),
);
const list = (await listRes.json()) as any[];
expect(list.some((s) => s.id === createdId && s.token === registerBody.server.token)).toBe(true);
});
test("POST 400s an invalid registration", async () => {
const res = await post("/api/admin/servers", { name: "", authMode: "offline" }, { "X-MCMapper-Admin-Token": ADMIN_TOKEN });
expect(res.status).toBe(400);
});
test("PATCH updates settings and DELETE removes the server", async () => {
const registerRes = await post(
"/api/admin/servers",
{ name: "admin-route-patch-delete", authMode: "offline" },
{ "X-MCMapper-Admin-Token": ADMIN_TOKEN },
);
const { server } = (await registerRes.json()) as any;
const patchRes = await patch(
`/api/admin/servers/${server.id}`,
{ anonymousChatAllowed: false, waypointFormat: "xaero" },
{ "X-MCMapper-Admin-Token": ADMIN_TOKEN },
);
expect(patchRes.status).toBe(200);
const patched = (await patchRes.json()) as any;
expect(patched.server.anonymousChatAllowed).toBe(false);
expect(patched.server.waypointFormat).toBe("xaero");
const deleteRes = await del(`/api/admin/servers/${server.id}`, { "X-MCMapper-Admin-Token": ADMIN_TOKEN });
expect(deleteRes.status).toBe(200);
const listRes = await app.handle(
new Request("http://localhost/api/admin/servers", { headers: { "X-MCMapper-Admin-Token": ADMIN_TOKEN } }),
);
const list = (await listRes.json()) as any[];
expect(list.some((s) => s.id === server.id)).toBe(false);
});
});
+49
View File
@@ -8,6 +8,7 @@ import { minio, TILE_BUCKET, ensureTileBucket } from "./minio";
import { redeemLinkCode, getAccountForSession, revokeSession } from "./link";
import { createMarker, listMarkers, deleteMarker, updateMarker, shareMarkerToChat } from "./markers";
import { getExportSections } from "./export";
import { isAdminTokenValid, listServers as listServersAdmin, registerServer, updateServerSettings, deleteServer } from "./admin";
const MARKER_SHARE_ERROR_STATUS: Record<string, number> = {
not_found: 404,
@@ -229,6 +230,54 @@ export const app = new Elysia()
}
return result;
})
// Admin: a single shared secret (MCMAPPER_ADMIN_TOKEN), not a per-account role — see
// admin.ts's doc comment. Replaces the Phase 1 `bun run seed` env-var flow as the real
// server-registration path.
.get("/api/admin/servers", async ({ headers, set }) => {
if (!isAdminTokenValid(headers["x-mcmapper-admin-token"])) {
set.status = 401;
return { error: "unauthenticated" };
}
return listServersAdmin();
})
.post("/api/admin/servers", async ({ body, headers, set }) => {
if (!isAdminTokenValid(headers["x-mcmapper-admin-token"])) {
set.status = 401;
return { error: "unauthenticated" };
}
const { name, authMode } = body as { name?: string; authMode?: string };
if (!name || !authMode) {
set.status = 400;
return { ok: false, error: "missing_fields" };
}
const result = await registerServer(name, authMode);
if (!result.ok) set.status = 400;
return result;
})
.patch("/api/admin/servers/:id", async ({ params, body, headers, set }) => {
if (!isAdminTokenValid(headers["x-mcmapper-admin-token"])) {
set.status = 401;
return { error: "unauthenticated" };
}
const { name, authMode, anonymousChatAllowed, waypointFormat } = body as {
name?: string;
authMode?: string;
anonymousChatAllowed?: boolean;
waypointFormat?: string;
};
const result = await updateServerSettings(params.id, { name, authMode, anonymousChatAllowed, waypointFormat });
if (!result.ok) set.status = result.error === "not_found" ? 404 : 400;
return result;
})
.delete("/api/admin/servers/:id", async ({ params, headers, set }) => {
if (!isAdminTokenValid(headers["x-mcmapper-admin-token"])) {
set.status = 401;
return { error: "unauthenticated" };
}
const result = await deleteServer(params.id);
if (!result.ok) set.status = 404;
return result;
})
.ws("/ws", {
open: wsGateway.open,
message: wsGateway.message,