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
+32 -13
View File
@@ -61,20 +61,37 @@ docker compose up
migrations on startup (see `api/src/db/migrate.ts`); `worker` consumes the `mcmapper:dirty-chunks`
Redis stream via a consumer group (`mcmapper-workers`) so multiple instances split work safely.
### Phase 1: connecting a mod instance
### Connecting a mod instance
There's no admin registration API yet (Phase 6) — seed one server row by hand, then point the
mod's `MapperConfig` at the same token:
Register a server via the admin panel at `/admin` (set `MCMAPPER_ADMIN_TOKEN` first — see
"Admin panel" below), or seed one row by hand for scripted/headless setup:
```
docker compose run --rm api bun run seed
```
reads `MCMAPPER_SEED_SERVER_NAME`/`MCMAPPER_SEED_SERVER_TOKEN` from `api/.env.example` (edit
those first, or override with `-e`). Once the mod connects and sends its initial chunk backfill,
tiles appear at `GET /api/tiles/:serverId/:dimension/:zoom/:tileX/:tileY.png` (zoom is always `0`
for now — see `worker/src/render/cpu.rs`) and the frontend's Leaflet viewer picks them up
automatically from `GET /api/servers`.
those first, or override with `-e`). Either way, point the mod's `MapperConfig#serverToken` at
the resulting token. Once the mod connects and sends its initial chunk backfill, tiles appear at
`GET /api/tiles/:serverId/:dimension/:zoom/:tileX/:tileY.png` (zoom is always `0` for now — see
`worker/src/render/cpu.rs`) and the frontend's Leaflet viewer picks them up automatically from
`GET /api/servers`.
### Admin panel
`/admin` (linked from the map page's header) manages the server registry: register new servers
(generates their token — never admin-supplied, so it can't collide with or be guessed from
anything else), and edit `authMode`, `anonymousChatAllowed`, and `waypointFormat` per server.
It's gated behind a single shared secret, not a per-account role (this is a single-operator
backend) — set `MCMAPPER_ADMIN_TOKEN` in `api/`'s untracked `.env` (see `api/.env.example`),
restart `api`, then enter that same value into the panel's unlock prompt. Leaving it unset
disables every `/api/admin/*` route (401), it does not default to open. The token is remembered
in the browser's `localStorage` after unlocking, same pattern as the player-facing session token
(see `api/src/link.ts`'s doc comment).
Settings not exposed here yet (which map types render, dimension filtering, player-position
visibility) don't have underlying features built for them either — no point in a knob nothing
reads. They'll gain admin UI alongside the feature that needs them.
## Running tests
@@ -121,12 +138,14 @@ bunx playwright test
(unlike the `bun test` section above): throwaway Postgres/Redis/MinIO containers
(`mcmapper-e2e-*`, distinct names/ports from the `bun test` ones so both can run at once),
migrations, a seeded server + linked account/session + a 5x5-chunk terrain footprint around the
world origin, then the `api`/`frontend`/proxy processes. `global-teardown.ts` kills every spawned
process and removes the containers afterward. Covers the two UI flows most worth a real
click-through: the marker click-to-place/edit popup (`tests/markers.spec.ts`, including that a
marker created while linked shows up in a second browser context with the same session — the
cross-device sync claim) and the region-select drag + glTF export (`tests/region-export.spec.ts`,
including a real triggered file download).
world origin, then the `api`/`frontend`/proxy processes (the `api` process is started with a
fixed `MCMAPPER_ADMIN_TOKEN` for `tests/admin.spec.ts` to use — see `config.ts`'s `ADMIN_TOKEN`).
`global-teardown.ts` kills every spawned process and removes the containers afterward. Covers the
UI flows most worth a real click-through: the marker click-to-place/edit popup
(`tests/markers.spec.ts`, including that a marker created while linked shows up in a second
browser context with the same session — the cross-device sync claim), the region-select drag +
glTF export (`tests/region-export.spec.ts`, including a real triggered file download), and the
admin panel's token gate + server register/edit/delete round trip (`tests/admin.spec.ts`).
## Attribution
+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,
+5
View File
@@ -31,6 +31,10 @@ export const MINIO_SECRET_KEY = "mcmapper-e2e-only";
export const PROXY_ORIGIN = `http://localhost:${PROXY_PORT}`;
// Fixed, not random: admin.spec.ts needs to know it up front, and this only ever gates the
// throwaway e2e api instance.
export const ADMIN_TOKEN = "e2e-admin-token";
export const STATE_FILE = join(import.meta.dirname, ".e2e-state.json");
export type E2eState = {
@@ -41,6 +45,7 @@ export type E2eState = {
serverToken: string;
sessionToken: string;
accountId: string;
adminToken: string;
};
// Read by spec files — by the time tests run, global-setup.ts has already written this.
+3
View File
@@ -12,6 +12,7 @@ import { execSync, spawn, type ChildProcess } from "node:child_process";
import { writeFileSync } from "node:fs";
import postgres from "postgres";
import {
ADMIN_TOKEN,
API_DIR,
API_PORT,
DATABASE_URL,
@@ -145,6 +146,7 @@ export default async function globalSetup() {
MINIO_USE_SSL: "false",
MINIO_ACCESS_KEY,
MINIO_SECRET_KEY,
MCMAPPER_ADMIN_TOKEN: ADMIN_TOKEN,
});
await waitFor(() => httpOk(`http://localhost:${API_PORT}/health`), "api");
@@ -171,6 +173,7 @@ export default async function globalSetup() {
serverToken,
sessionToken,
accountId: account!.id,
adminToken: ADMIN_TOKEN,
}),
);
+75
View File
@@ -0,0 +1,75 @@
// Covers the admin panel's token-unlock flow and server registry management (Phase 6) — the
// api-level admin.ts/index.ts routes are already unit/integration tested, so this focuses on the
// browser-side glue: the token gate, and register/edit/delete round-tripping through the real UI.
import { test, expect } from "@playwright/test";
import { readE2eState } from "../config";
const state = readE2eState();
test.describe("admin panel", () => {
test.use({ storageState: { cookies: [], origins: [] } });
test("wrong token shows an error and does not unlock", async ({ page }) => {
await page.goto("/admin");
await page.getByTestId("admin-token-input").fill("not-the-right-token");
await page.getByTestId("admin-unlock").click();
await expect(page.getByTestId("admin-error")).toBeVisible();
await expect(page.getByTestId("admin-token-input")).toBeVisible();
});
test("correct token unlocks and lists the seeded e2e server", async ({ page }) => {
await page.goto("/admin");
await page.getByTestId("admin-token-input").fill(state.adminToken);
await page.getByTestId("admin-unlock").click();
await expect(page.getByTestId("admin-lock")).toBeVisible();
await expect(page.locator('[data-testid="admin-server-row"][data-server-name="e2e-server"]')).toBeVisible();
});
test("registering a new server adds it to the list with a generated token", async ({ page }) => {
await page.goto("/admin");
await page.getByTestId("admin-token-input").fill(state.adminToken);
await page.getByTestId("admin-unlock").click();
await page.getByTestId("admin-new-name").fill("e2e-admin-created");
await page.getByTestId("admin-new-authmode").selectOption("online");
await page.getByTestId("admin-register-submit").click();
const row = page.locator('[data-testid="admin-server-row"][data-server-name="e2e-admin-created"]');
await expect(row).toBeVisible();
await expect(row.getByTestId("admin-server-token")).not.toHaveText("");
});
test("editing settings and saving persists across a reload", async ({ page }) => {
await page.goto("/admin");
await page.getByTestId("admin-token-input").fill(state.adminToken);
await page.getByTestId("admin-unlock").click();
const row = page.locator('[data-testid="admin-server-row"][data-server-name="e2e-server"]');
await row.getByTestId("admin-server-waypointformat").selectOption("xaero");
await row.getByTestId("admin-server-anonchat").uncheck();
await row.getByTestId("admin-server-save").click();
await expect(row.getByTestId("admin-server-status")).toHaveText("saved");
// The admin token is remembered in localStorage (see admin.js's init()), so a reload
// auto-unlocks again rather than showing the token prompt.
await page.reload();
const reloadedRow = page.locator('[data-testid="admin-server-row"][data-server-name="e2e-server"]');
await expect(reloadedRow.getByTestId("admin-server-waypointformat")).toHaveValue("xaero");
await expect(reloadedRow.getByTestId("admin-server-anonchat")).not.toBeChecked();
});
test("deleting a server removes it from the list", async ({ page }) => {
await page.goto("/admin");
await page.getByTestId("admin-token-input").fill(state.adminToken);
await page.getByTestId("admin-unlock").click();
await page.getByTestId("admin-new-name").fill("e2e-admin-deleteme");
await page.getByTestId("admin-new-authmode").selectOption("offline");
await page.getByTestId("admin-register-submit").click();
const row = page.locator('[data-testid="admin-server-row"][data-server-name="e2e-admin-deleteme"]');
await expect(row).toBeVisible();
await row.getByTestId("admin-server-delete").click();
await expect(row).toHaveCount(0);
});
});
+3
View File
@@ -6,13 +6,16 @@ import { join } from "path";
// viewer (see public/js/mesh.js). Chat box, marker tool, and admin panel land in later phases.
const renderIndex = pug.compileFile(join(import.meta.dir, "views/index.pug"));
const renderScene3d = pug.compileFile(join(import.meta.dir, "views/scene3d.pug"));
const renderAdmin = pug.compileFile(join(import.meta.dir, "views/admin.pug"));
const app = new Elysia()
.get("/", () => new Response(renderIndex({}), { headers: { "Content-Type": "text/html" } }))
.get("/3d", () => new Response(renderScene3d({}), { headers: { "Content-Type": "text/html" } }))
.get("/admin", () => new Response(renderAdmin({}), { headers: { "Content-Type": "text/html" } }))
.get("/health", () => ({ status: "ok" }))
.get("/css/tailwind.css", () => Bun.file(join(import.meta.dir, "public/css/tailwind.css")))
.get("/js/map.js", () => Bun.file(join(import.meta.dir, "public/js/map.js")))
.get("/js/admin.js", () => Bun.file(join(import.meta.dir, "public/js/admin.js")))
.get("/js/coords.js", () => Bun.file(join(import.meta.dir, "public/js/coords.js")))
.get("/js/colors.js", () => Bun.file(join(import.meta.dir, "public/js/colors.js")))
.get("/js/mesh.js", () => Bun.file(join(import.meta.dir, "public/js/mesh.js")))
+98
View File
@@ -0,0 +1,98 @@
// Admin panel (Phase 6): a single shared token gates /api/admin/* (see api/src/admin.ts's doc
// comment) — there's no per-account admin role, so this page just asks for that token once and
// keeps it in localStorage for the browser session, sending it back via the
// X-MCMapper-Admin-Token header on every admin request (same bearer-header pattern as the
// player-facing X-MCMapper-Session, see map.js).
const ADMIN_TOKEN_STORAGE_KEY = "mcmapper_admin_token";
function adminpanel() {
return {
tokenInput: "",
token: localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY) || null,
unlocked: false,
error: "",
loading: true,
servers: [],
newServer: { name: "", authMode: "offline" },
registerStatus: "",
async init() {
if (this.token) await this.tryLoad();
},
async tryLoad() {
const res = await fetch("/api/admin/servers", { headers: { "X-MCMapper-Admin-Token": this.token } });
if (!res.ok) {
this.error = res.status === 401 ? "invalid admin token" : `error loading servers (${res.status})`;
this.unlocked = false;
this.token = null;
localStorage.removeItem(ADMIN_TOKEN_STORAGE_KEY);
this.loading = false;
return;
}
this.servers = await res.json();
this.unlocked = true;
this.error = "";
this.loading = false;
},
async unlock() {
this.token = this.tokenInput;
localStorage.setItem(ADMIN_TOKEN_STORAGE_KEY, this.token);
await this.tryLoad();
},
lock() {
this.token = null;
this.unlocked = false;
this.servers = [];
this.tokenInput = "";
localStorage.removeItem(ADMIN_TOKEN_STORAGE_KEY);
},
async saveServer(server) {
server.status = "saving…";
const res = await fetch(`/api/admin/servers/${server.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", "X-MCMapper-Admin-Token": this.token },
body: JSON.stringify({
name: server.name,
authMode: server.authMode,
anonymousChatAllowed: server.anonymousChatAllowed,
waypointFormat: server.waypointFormat,
}),
});
server.status = res.ok ? "saved" : "save failed";
},
async removeServer(server) {
const res = await fetch(`/api/admin/servers/${server.id}`, {
method: "DELETE",
headers: { "X-MCMapper-Admin-Token": this.token },
});
if (res.ok) this.servers = this.servers.filter((s) => s.id !== server.id);
},
async registerServer() {
if (!this.newServer.name.trim()) {
this.registerStatus = "name is required";
return;
}
const res = await fetch("/api/admin/servers", {
method: "POST",
headers: { "Content-Type": "application/json", "X-MCMapper-Admin-Token": this.token },
body: JSON.stringify({ name: this.newServer.name, authMode: this.newServer.authMode }),
});
const result = await res.json();
if (!result.ok) {
this.registerStatus = `failed: ${result.error}`;
return;
}
this.servers.push(result.server);
this.newServer = { name: "", authMode: "offline" };
this.registerStatus = "";
},
};
}
window.adminpanel = adminpanel;
+69
View File
@@ -0,0 +1,69 @@
doctype html
html(lang="en")
head
meta(charset="utf-8")
meta(name="viewport" content="width=device-width, initial-scale=1")
title MCMapper — Admin
link(rel="stylesheet" href="/css/tailwind.css")
script(type="module" src="/js/admin.js")
script(defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js")
body.bg-neutral-900.text-neutral-100.min-h-screen
div.max-w-3xl.mx-auto.p-6.space-y-6(x-data="adminpanel()" x-init="init()")
header.flex.items-center.gap-4
h1.text-lg.font-semibold Server admin
a.text-sm.text-neutral-400.underline(href="/") map
template(x-if="!unlocked")
div.space-y-2.max-w-sm
p.text-sm.text-neutral-400 Enter the admin token (MCMAPPER_ADMIN_TOKEN on the api service) to manage servers.
input.w-full.bg-neutral-800.text-sm.px-2.py-1.rounded.border.border-neutral-700(
type="password" placeholder="admin token" x-model="tokenInput"
x-on:keydown.enter="unlock" data-testid="admin-token-input")
button.px-3.py-1.bg-emerald-700.rounded.text-sm(x-on:click="unlock" data-testid="admin-unlock") Unlock
p.text-xs.text-red-400(x-show="error" x-text="error" data-testid="admin-error")
template(x-if="unlocked")
div.space-y-6
div.flex.items-center.justify-between
p.text-xs.text-neutral-500 Unlocked for this browser session.
button.text-xs.underline(x-on:click="lock" data-testid="admin-lock") lock
section.space-y-2
h2.text-sm.font-semibold Servers
div.space-y-2
template(x-for="server in servers" x-bind:key="server.id")
div.bg-neutral-800.rounded.p-3.space-y-2(data-testid="admin-server-row" x-bind:data-server-name="server.name")
div.flex.items-center.justify-between.gap-2
input.flex-1.bg-neutral-900.text-sm.px-2.py-1.rounded.border.border-neutral-700(
type="text" x-model="server.name" data-testid="admin-server-name")
span.text-xs.text-neutral-500.font-mono.truncate(style="max-width: 14rem;" x-text="server.token" data-testid="admin-server-token")
div.grid.grid-cols-3.gap-2
select.bg-neutral-900.text-xs.px-2.py-1.rounded.border.border-neutral-700(
x-model="server.authMode" data-testid="admin-server-authmode")
option(value="online") online
option(value="offline") offline
select.bg-neutral-900.text-xs.px-2.py-1.rounded.border.border-neutral-700(
x-model="server.waypointFormat" data-testid="admin-server-waypointformat")
option(value="journeymap") journeymap
option(value="xaero") xaero
option(value="off") off
label.flex.items-center.gap-1.text-xs.text-neutral-300
input(type="checkbox" x-model="server.anonymousChatAllowed" data-testid="admin-server-anonchat")
| anonymous chat
div.flex.gap-2
button.px-2.py-1.bg-emerald-700.rounded.text-xs(x-on:click="saveServer(server)" data-testid="admin-server-save") Save
button.px-2.py-1.bg-red-800.rounded.text-xs(x-on:click="removeServer(server)" data-testid="admin-server-delete") Delete
p.text-xs.text-amber-400(x-show="server.status" x-text="server.status" data-testid="admin-server-status")
p.text-sm.text-neutral-500(x-show="!loading && servers.length === 0") No servers registered yet.
section.space-y-2.border-t.border-neutral-700.pt-4
h2.text-sm.font-semibold Register a new server
div.flex.gap-2.flex-wrap
input.flex-1.bg-neutral-800.text-sm.px-2.py-1.rounded.border.border-neutral-700(
type="text" placeholder="server name" x-model="newServer.name" data-testid="admin-new-name")
select.bg-neutral-800.text-sm.px-2.py-1.rounded.border.border-neutral-700(
x-model="newServer.authMode" data-testid="admin-new-authmode")
option(value="online") online
option(value="offline") offline
button.px-3.py-1.bg-emerald-700.rounded.text-sm(x-on:click="registerServer" data-testid="admin-register-submit") Register
p.text-xs.text-neutral-400(x-show="registerStatus" x-text="registerStatus" data-testid="admin-register-status")
+1
View File
@@ -22,6 +22,7 @@ html(lang="en")
header.px-4.py-2.flex.items-center.gap-4.border-b.border-neutral-700
h1.text-lg.font-semibold MCMapper
a.text-sm.text-neutral-400.underline(href="/3d") 3D view
a.text-sm.text-neutral-400.underline(href="/admin") admin
span.text-sm.text-neutral-400(x-show="!loading && server")
| Viewing:
span(x-text="server?.name")