diff --git a/components/MapCard.tsx b/components/MapCard.tsx
index e5cfc74..92efd55 100644
--- a/components/MapCard.tsx
+++ b/components/MapCard.tsx
@@ -3,6 +3,7 @@ import { useI18n } from "@/components/I18nProvider";
import Image from "next/image";
import Link from "next/link";
import { ArrowUpRight, MapPin } from "lucide-react";
+import { Username } from "./Username";
export type CardMap = {
id: string;
name: string;
@@ -11,6 +12,8 @@ export type CardMap = {
panorama_count: number;
image?: string | null;
credit?: string;
+ creditGlow?: boolean;
+ creditAppearance?: import("@/shared/profile-appearance").ProfileAppearance;
server?: { name: string } | undefined;
bounds: { minX: number; minZ: number; maxX: number; maxZ: number };
};
@@ -51,7 +54,12 @@ export function MapCard({
diff --git a/components/Moderation.tsx b/components/Moderation.tsx
index 2cd3b9e..3674123 100644
--- a/components/Moderation.tsx
+++ b/components/Moderation.tsx
@@ -9,6 +9,7 @@ import { PanoramaPreview } from "./PanoramaPreview";
import { reportCategories } from "@/shared/moderation";
import type { Appeal } from "./MapAppeal";
import { ModeratorSettings } from "./ModeratorSettings";
+import { ModeratorPrefixes } from "./ModeratorPrefixes";
type ReviewMap = {
id: string;
name: string;
@@ -114,6 +115,7 @@ export function Moderation() {
return (
<>
{authorized &&
}
+ {authorized &&
}
{error && (
{t(error)}
diff --git a/components/ModeratorPrefixes.tsx b/components/ModeratorPrefixes.tsx
new file mode 100644
index 0000000..99e452b
--- /dev/null
+++ b/components/ModeratorPrefixes.tsx
@@ -0,0 +1,218 @@
+"use client";
+import { useState } from "react";
+import { Tags } from "lucide-react";
+import { useI18n } from "./I18nProvider";
+import { api } from "@/lib/client-api";
+import {
+ prefixIcons,
+ prefixUpdateSchema,
+ type BadgeUser,
+ type PrefixIcon,
+} from "@/shared/profile-appearance";
+import { Username } from "./Username";
+
+export function ModeratorPrefixes() {
+ const { t } = useI18n();
+ const [search, setSearch] = useState(""),
+ [users, setUsers] = useState([]),
+ [selected, setSelected] = useState(null);
+ const [prefix, setPrefix] = useState(""),
+ [icon, setIcon] = useState("star"),
+ [busy, setBusy] = useState(false),
+ [error, setError] = useState(""),
+ [saved, setSaved] = useState(false),
+ [searched, setSearched] = useState(false);
+ async function find(event: React.FormEvent) {
+ event.preventDefault();
+ setBusy(true);
+ setError("");
+ setSaved(false);
+ setSelected(null);
+ try {
+ const result = await api<{ users: BadgeUser[] }>(
+ "moderation/users?q=" + encodeURIComponent(search),
+ );
+ setUsers(result.users);
+ setSearched(true);
+ } catch (failure) {
+ setError((failure as Error).message);
+ } finally {
+ setBusy(false);
+ }
+ }
+ async function save(remove = false) {
+ if (!selected || busy) return;
+ const body = prefixUpdateSchema.safeParse({
+ prefix: remove ? null : prefix,
+ icon,
+ });
+ if (!body.success) {
+ setError(
+ "Use a prefix of 1–24 characters without hidden controls or markup",
+ );
+ return;
+ }
+ setBusy(true);
+ setError("");
+ setSaved(false);
+ try {
+ const result = await api<{ user: BadgeUser }>(
+ "moderation/users/" + selected.id,
+ body.data,
+ "PATCH",
+ );
+ setSelected(result.user);
+ setPrefix(result.user.custom_prefix ?? "");
+ setUsers((users) =>
+ users.map((user) => (user.id === result.user.id ? result.user : user)),
+ );
+ setSaved(true);
+ } catch (failure) {
+ setError((failure as Error).message);
+ } finally {
+ setBusy(false);
+ }
+ }
+ const iconLabels = {
+ star: t("Star"),
+ crown: t("Crown"),
+ heart: t("Heart"),
+ flame: t("Flame"),
+ gem: t("Gem"),
+ trophy: t("Trophy"),
+ sparkles: t("Sparkles"),
+ };
+ return (
+
+
+
+
+
{t("Player prefixes")}
+
{t("Find a player and assign a custom prefix with an icon.")}
+
+
+
+ {searched && users.length === 0 && (
+ {t("No players found.")}
+ )}
+ {users.length > 0 && (
+
+ {users.map((user) => (
+ -
+
+
+ ))}
+
+ )}
+ {selected && (
+
+ )}
+ {error && (
+
+ {t(error)}
+
+ )}
+ {saved && (
+
+ {t("Player prefix saved.")}
+
+ )}
+
+ );
+}
diff --git a/components/MultiplayerGame.tsx b/components/MultiplayerGame.tsx
index 8ca9c76..fc9429b 100644
--- a/components/MultiplayerGame.tsx
+++ b/components/MultiplayerGame.tsx
@@ -1,4 +1,5 @@
"use client";
+import { Username } from "./Username";
import { useCallback, useEffect, useRef, useState } from "react";
import {
@@ -139,7 +140,7 @@ export function MultiplayerGame({
{room.players.map((player) => (
- {player.name}
+
{player.id === room.you && {t("(you)")}}
{player.left
diff --git a/components/MultiplayerRoom.tsx b/components/MultiplayerRoom.tsx
index b3bcb1a..12372a4 100644
--- a/components/MultiplayerRoom.tsx
+++ b/components/MultiplayerRoom.tsx
@@ -11,6 +11,7 @@ import { useI18n } from "./I18nProvider";
import { api } from "@/lib/client-api";
import { roundClock } from "@/shared/round-clock";
import type { RoomView } from "@/shared/multiplayer";
+import { Username } from "./Username";
export function MultiplayerRoom({ id }: { id: string }) {
const { t, formatNumber } = useI18n();
@@ -247,7 +248,10 @@ export function MultiplayerRoom({ id }: { id: string }) {
{player.id === room.hostId && (
)}{" "}
- {player.name}{" "}
+ {" "}
{player.id === room.you && {t("(you)")}}
{owner && player.id !== room.you && (
@@ -304,7 +308,16 @@ export function MultiplayerRoom({ id }: { id: string }) {
<>
{" "}
- {winners.map((player) => player.name).join(" & ")} ·{" "}
+ {winners.map((player, index) => (
+
+ {index > 0 ? " & " : ""}
+
+
+ ))}{" "}
+ ·{" "}
{t("{count} points", {
count: formatNumber(winners[0]?.total ?? 0),
})}
@@ -334,7 +347,10 @@ export function MultiplayerRoom({ id }: { id: string }) {
>
{player.rank} |
- {player.name}
+
{player.left && · {t("Left room")}}
|
{room.rounds.map((turn) => (
diff --git a/components/ReportBugButton.tsx b/components/ReportBugButton.tsx
new file mode 100644
index 0000000..6f2d979
--- /dev/null
+++ b/components/ReportBugButton.tsx
@@ -0,0 +1,19 @@
+"use client";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { Bug } from "lucide-react";
+import { useI18n } from "./I18nProvider";
+export function ReportBugButton() {
+ const { t } = useI18n();
+ const pathname = usePathname();
+ return (
+
+
+ {t("Report a bug")}
+
+ );
+}
diff --git a/components/RoomChat.tsx b/components/RoomChat.tsx
index 64424e5..4753d96 100644
--- a/components/RoomChat.tsx
+++ b/components/RoomChat.tsx
@@ -1,4 +1,5 @@
"use client";
+import { Username } from "./Username";
import { useCallback, useEffect, useRef, useState } from "react";
import { MessageCircle, Send, X } from "lucide-react";
@@ -197,7 +198,7 @@ export function RoomChat({
className={message.playerId === playerId ? "room-chat-mine" : ""}
>
- {message.name}
+
{message.playerId === playerId && ` ${t("(you)")}`}
{message.text}
diff --git a/components/Shell.tsx b/components/Shell.tsx
index a706fcf..778a604 100644
--- a/components/Shell.tsx
+++ b/components/Shell.tsx
@@ -6,6 +6,7 @@ import { LanguageSwitcher } from "./LanguageSwitcher";
import { user } from "@/lib/supabase";
import { Brand } from "./Brand";
import { SiteNav } from "./SiteNav";
+import { ReportBugButton } from "./ReportBugButton";
export async function Header() {
const { t } = await getI18n();
const account = await user();
@@ -14,12 +15,15 @@ export async function Header() {
-
- {account ? t("My account") : t("Log in")}
-
+
+
+
+ {account ? t("My account") : t("Log in")}
+
+
);
diff --git a/components/Support.tsx b/components/Support.tsx
new file mode 100644
index 0000000..47ffe58
--- /dev/null
+++ b/components/Support.tsx
@@ -0,0 +1,465 @@
+"use client";
+import { useCallback, useEffect, useRef, useState } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { Bug, ImagePlus, Send, Sparkles } from "lucide-react";
+import { api } from "@/lib/client-api";
+import {
+ bugReportSchema,
+ bugMessageSchema,
+ screenshotLimit,
+ type BugReportView,
+} from "@/shared/bug-reports";
+import { useI18n } from "./I18nProvider";
+import { Username } from "./Username";
+
+type Inbox = {
+ ready: boolean;
+ signedIn: boolean;
+ displayName: string | null;
+ nameGlow: boolean;
+ reports: {
+ id: string;
+ title: string;
+ state: string;
+ created_at: string;
+ linear_identifier: string | null;
+ }[];
+};
+export function SupportInbox({ pagePath }: { pagePath: string }) {
+ const { t, formatDate } = useI18n(),
+ router = useRouter();
+ const [inbox, setInbox] = useState(null),
+ [error, setError] = useState(""),
+ [busy, setBusy] = useState(false);
+ const [title, setTitle] = useState(""),
+ [description, setDescription] = useState(""),
+ [name, setName] = useState(""),
+ [file, setFile] = useState(null);
+ const nonce = useRef(null);
+ useEffect(() => {
+ let stopped = false;
+ api("bug-reports")
+ .then((value) => {
+ if (!stopped) setInbox(value);
+ })
+ .catch((failure) => {
+ if (!stopped) setError(failure.message);
+ });
+ return () => {
+ stopped = true;
+ };
+ }, []);
+ async function submit() {
+ if (busy) return;
+ const clientId = (nonce.current ??= crypto.randomUUID());
+ const body = {
+ clientId,
+ title,
+ description,
+ pagePath,
+ displayName: inbox?.signedIn ? undefined : name,
+ screenshot: !!file,
+ };
+ if (!bugReportSchema.safeParse(body).success) {
+ setError(
+ "Add a short title, at least 15 characters describing the bug, and your player name.",
+ );
+ return;
+ }
+ if (
+ file &&
+ (!["image/png", "image/jpeg", "image/webp"].includes(file.type) ||
+ file.size > screenshotLimit)
+ ) {
+ setError("Choose a PNG, JPG, or WebP screenshot smaller than 5 MB");
+ return;
+ }
+ setBusy(true);
+ setError("");
+ try {
+ const report = await api<{
+ id: string;
+ state: string;
+ upload: string | null;
+ }>("bug-reports", body);
+ if (report.upload && file) {
+ const response = await fetch(report.upload, {
+ method: "PUT",
+ headers: { "Content-Type": file.type },
+ body: file,
+ signal: AbortSignal.timeout(60_000),
+ });
+ if (!response.ok)
+ throw new Error("Screenshot upload failed. Please try again.");
+ }
+ await api(`bug-reports/${report.id}/submit`, {});
+ router.push(`/support/${report.id}`);
+ } catch (failure) {
+ setError((failure as Error).message);
+ setBusy(false);
+ }
+ }
+ return (
+
+
+
+
+ {t("PLAYER SUPPORT")}
+
+ {t("Found a bug?")}
+ {t("Tell us what happened. We’ll reply here.")}
+ {!inbox?.signedIn && (
+
+ {t(
+ "Guest reports stay in this browser. Sign in to keep your conversation in your account.",
+ )}{" "}
+ {t("Log in")}
+
+ )}
+ {error && (
+
+ {t(error)}
+
+ )}
+ {inbox && !inbox.ready && (
+
+ {t(
+ "Bug reporting is temporarily unavailable. Please try again later.",
+ )}
+
+ )}
+
+
+
+ {t("Your bug reports")}
+ {!inbox ? (
+ {t("Loading…")}
+ ) : inbox.reports.length === 0 ? (
+
+ {t("Your reports and support replies will appear here.")}
+
+ ) : (
+
+ {inbox.reports.map((report) => (
+ -
+
+ {report.title}
+
+ {formatDate(report.created_at)} ·{" "}
+ {report.linear_identifier ??
+ t(
+ report.state === "draft"
+ ? "Draft"
+ : "Waiting for support",
+ )}
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+export function SupportConversation({ id }: { id: string }) {
+ const { t, formatDate } = useI18n();
+ const [report, setReport] = useState(null),
+ [error, setError] = useState(""),
+ [text, setText] = useState(""),
+ [busy, setBusy] = useState(false);
+ const pending = useRef<{ clientId: string; text: string } | null>(null),
+ version = useRef(0),
+ acting = useRef(false);
+ const load = useCallback(async () => {
+ if (acting.current) return;
+ const current = ++version.current;
+ try {
+ const value = await api(`bug-reports/${id}`);
+ if (current === version.current) {
+ setReport(value);
+ setError("");
+ }
+ } catch (failure) {
+ if (current === version.current) setError((failure as Error).message);
+ }
+ }, [id]);
+ useEffect(() => {
+ let stopped = false;
+ let timer: ReturnType;
+ const poll = async () => {
+ await load();
+ if (!stopped) timer = setTimeout(poll, document.hidden ? 30_000 : 10_000);
+ };
+ void poll();
+ return () => {
+ stopped = true;
+ version.current++;
+ clearTimeout(timer);
+ };
+ }, [load]);
+ async function action(kind: string, body: object) {
+ if (acting.current) return;
+ acting.current = true;
+ setBusy(true);
+ setError("");
+ const current = ++version.current;
+ try {
+ const value = await api(`bug-reports/${id}/${kind}`, body);
+ if (current === version.current) {
+ setReport(value);
+ if (kind === "messages") {
+ setText("");
+ pending.current = null;
+ }
+ }
+ } catch (failure) {
+ if (current === version.current) setError((failure as Error).message);
+ } finally {
+ acting.current = false;
+ setBusy(false);
+ }
+ }
+ async function send() {
+ const draft = text.trim();
+ const body =
+ pending.current?.text === draft
+ ? pending.current
+ : { text: draft, clientId: crypto.randomUUID() };
+ if (!bugMessageSchema.safeParse(body).success) {
+ setError("Use 1–6000 characters without hidden controls");
+ return;
+ }
+ pending.current = body;
+ await action("messages", body);
+ }
+ return (
+
+
+ {t("← Your bug reports")}
+
+ {error && (
+
+ {t(error)}{" "}
+
+
+ )}
+ {!report ? (
+
{t("Loading…")}
+ ) : (
+ <>
+
+ {t("PLAYER SUPPORT")} ·{" "}
+ {report.linear_identifier ?? t("Waiting for support")}
+
+
{report.title}
+ {report.state === "pending" && (
+
+ {t("Your report is saved. We’re connecting it to support.")}
+
+ )}
+ {report.state === "draft" && (
+ <>
+
+ {t(
+ "This report is not sent yet. Return to the form or finish sending it.",
+ )}
+
+
+ >
+ )}
+ {report.canClaim && (
+
+ )}
+ {report.glow_requested && (
+
+
+ {t("Support awarded you a username glow.")}{" "}
+ {!report.canClaim && (
+
+ {report.isGuest ? t("Log in") : t("Account settings")}
+
+ )}
+ {report.isGuest && !report.canClaim && (
+
+ {t(
+ "For guest reports, sign in and link this report to receive your reward.",
+ )}
+
+ )}
+
+ )}
+ {report.screenshot && (
+
+
+
+ )}
+
+ {report.messages.map((message) => (
+
+
+
+ {message.kind === "user" ? (
+
+ ) : (
+ t("Support")
+ )}
+
+ {formatDate(message.created_at)}
+
+
+ {message.kind === "system" ? t(message.body) : message.body}
+
+ {message.kind === "user" && (
+
+ {message.delivered
+ ? t("Delivered to support")
+ : t("Saved · waiting to send")}
+
+ )}
+
+ ))}
+
+ {report.state !== "draft" && (
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/components/Username.tsx b/components/Username.tsx
new file mode 100644
index 0000000..a8b89d9
--- /dev/null
+++ b/components/Username.tsx
@@ -0,0 +1,77 @@
+"use client";
+import {
+ Bug,
+ ShieldCheck,
+ Star,
+ Crown,
+ Heart,
+ Flame,
+ Gem,
+ Trophy,
+ Sparkles,
+} from "lucide-react";
+import { useI18n } from "./I18nProvider";
+import { glowColor, type ProfileAppearance } from "@/shared/profile-appearance";
+export const badgeIcons = {
+ star: Star,
+ crown: Crown,
+ heart: Heart,
+ flame: Flame,
+ gem: Gem,
+ trophy: Trophy,
+ sparkles: Sparkles,
+};
+export function Username({
+ name,
+ glow,
+ appearance = {},
+}: {
+ name: string;
+ glow?: boolean;
+ appearance?: ProfileAppearance;
+}) {
+ const { t } = useI18n();
+ const color = glowColor(appearance.glow_color),
+ enabled = glow ?? appearance.name_glow;
+ const Icon =
+ badgeIcons[appearance.custom_prefix_icon as keyof typeof badgeIcons] ??
+ Star;
+ return (
+
+ {appearance.role === "moderator" && (
+
+
+ {t("Moderator")}
+
+ )}
+ {appearance.bug_finder && (
+
+
+ {t("Bug finder")}
+
+ )}
+ {appearance.custom_prefix && (
+
+
+ {appearance.custom_prefix}
+
+ )}
+
+ {name}
+
+
+ );
+}
diff --git a/docs/bug-reports.md b/docs/bug-reports.md
new file mode 100644
index 0000000..3e996dc
--- /dev/null
+++ b/docs/bug-reports.md
@@ -0,0 +1,74 @@
+# Player bug reports
+
+The header's **Send bug report** button opens `/support`. Players can report a
+bug with an optional PNG, JPEG, or WebP screenshot up to 5 MB. Each submitted
+report creates one Medium-priority issue in Linear. Retries reuse server-generated
+issue and comment IDs. Screenshots are normalized, stripped of metadata, and
+uploaded to private Supabase Storage and Linear assets.
+
+Signed-in players keep reports in their account. Guests keep access through a
+180-day HttpOnly browser cookie. A guest can sign in in the same browser and
+choose **Keep this report in my account**. Clearing the cookie before linking
+loses guest access. Conversations and screenshots are private.
+
+## Linear commands
+
+Add a new comment on the report's issue beginning with:
+
+```text
+#player-report-send Dear user, we found the bug. Thank you for reporting it!
+```
+
+The text after the prefix appears in the player's conversation. Other staff
+comments remain internal. Player follow-ups appear in Linear with
+`#user-report-message`; the initial report uses the same prefix in its description.
+Editing an existing comment does not send another message.
+
+To grant or remove the actual account reward, add a separate new comment:
+
+```text
+#player-report-glow grant
+#player-report-glow revoke
+```
+
+Use one command per comment. A guest reward is held until the report is linked
+to an account. The glow appears beside account names on the dashboard, settings,
+leaderboard, and public map credits. Only verified Linear webhook commands can
+change rewards; browser writes cannot grant them.
+
+The follow-up migration `20261008075322_profile_identity.sql` adds a permanent
+**Bug finder** badge when a glow is granted. Revoking the glow preserves the badge.
+Guest reports retain confirmed-bug recognition when linked after a glow revoke.
+Players with an active glow can choose any hex color in **Settings → Username glow**.
+Moderators display a shield badge from their account role and can find an account
+in **Moderation → Player prefixes** to assign, change, or remove a custom text/icon
+prefix. Account IDs disambiguate players with identical names. Prefixes do not
+change permissions. These decorations appear in account views, leaderboards,
+map credits, support conversations, and authenticated multiplayer rosters and chat. Guests cannot gain
+badges by copying a registered player's display name.
+
+## Deployment setup
+
+Apply `20261008071054_bug_reports.sql` and `20261008075322_profile_identity.sql`
+before deploying. Production environment
+variables in Vercel:
+
+- `LINEAR_API_KEY`: sensitive personal API key with Read, Create issues, and
+ Create comments access to the support team.
+- `LINEAR_WEBHOOK_SECRET`: sensitive signing secret of the team's Linear webhook.
+- `LINEAR_TEAM_ID`: support team UUID.
+- `LINEAR_PROJECT_ID`: optional project UUID or Linear project identifier.
+
+Enable **Comment** events on the team webhook and set its URL to
+`https://minegeo.vercel.app/api/support/linear`. Redeploy after saving environment
+variables. Keys stay server-side and must never be pasted into issue descriptions,
+client configuration, or source uploads.
+
+Webhook requests require a matching raw-body HMAC signature and a timestamp
+within 60 seconds. Duplicate events have no effect. Delivery to Linear happens
+after the HTTP response; opening the conversation retries interrupted or failed
+delivery. The page shows saved/pending and delivered states.
+
+Validation: `npm test`, `npm run typecheck`, `npm run build`, and
+`node scripts/test-bug-reports-sql.mjs` (uses the isolated PGlite installation under
+`.data/sql-verification`, as the other SQL verification scripts do).
diff --git a/docs/vercel-deployment.md b/docs/vercel-deployment.md
index e3890fd..5f8005a 100644
--- a/docs/vercel-deployment.md
+++ b/docs/vercel-deployment.md
@@ -49,3 +49,9 @@ private and can be played without moderation; publication is blocked. See
The script targets the existing MineGeo project. Set `VERCEL_PROJECT_ID` and
`VERCEL_ORG_ID` to deploy a copy to another Vercel project.
+
+Player bug reporting requires `20261008071054_bug_reports.sql` and the Linear
+server credentials described in [bug-reports.md](bug-reports.md). Configure the
+Comment webhook before deploying so staff replies and glow commands reach players.
+Glow colors and user prefixes also require `20261008075322_profile_identity.sql`
+before deploying the updated account, catalog, and multiplayer pages.
diff --git a/lib/account-api.ts b/lib/account-api.ts
index 43412ba..8c4013f 100644
--- a/lib/account-api.ts
+++ b/lib/account-api.ts
@@ -70,13 +70,27 @@ export async function accountApi(
});
if (request.method === "PATCH") {
const body = profileUpdateSchema.parse(await context.jsonBody(request));
- await accountProfile(account);
+ const current = await accountProfile(account);
+ if (body.glowColor !== undefined && !current.name_glow)
+ return json(
+ { error: "Earn a username glow before choosing its color" },
+ 403,
+ );
const profile = required(
await admin()
.from("profiles")
- .update({ display_name: body.displayName })
+ .update({
+ ...(body.displayName !== undefined
+ ? { display_name: body.displayName }
+ : {}),
+ ...(body.glowColor !== undefined
+ ? { glow_color: body.glowColor }
+ : {}),
+ })
.eq("id", account.id)
- .select("id,display_name,role")
+ .select(
+ "id,display_name,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon",
+ )
.single(),
);
return json({ profile });
diff --git a/lib/api.ts b/lib/api.ts
index c74e39c..a769cfd 100644
--- a/lib/api.ts
+++ b/lib/api.ts
@@ -16,6 +16,8 @@ import { ServiceError, checkServiceError } from "./service-error";
import { requestOrigin, verifyOrigin } from "./request-origin";
import { accountApi } from "./account-api";
import { multiplayerApi } from "./multiplayer-api";
+import { bugReportApi } from "./bug-report-api";
+import { moderatorUsersApi } from "./moderator-users-api";
import { contributionCutoff, reportSchema } from "../shared/moderation";
import { siteSettingsSchema } from "../shared/site-settings";
import { getSiteSettings } from "./site-settings";
@@ -119,6 +121,8 @@ export async function handle(request: NextRequest, path: string[]) {
return await accountApi(request, path, { jsonBody, limit });
if (path[0] === "rooms")
return await multiplayerApi(request, path, { jsonBody, limit });
+ if (path[0] === "bug-reports")
+ return await bugReportApi(request, path, { jsonBody, limit });
const client = admin(),
method = request.method;
if (path[0] === "site-settings" && path.length === 1 && method === "GET") {
@@ -900,6 +904,8 @@ export async function handle(request: NextRequest, path: string[]) {
}
if (path[0] === "moderation") {
const reviewer = await moderator(request);
+ if (path[1] === "users")
+ return await moderatorUsersApi(request, path, reviewer, jsonBody);
if (path[1] === "settings" && path.length === 2 && method === "PATCH") {
const body = siteSettingsSchema.parse(await jsonBody(request));
const enabled = required(
diff --git a/lib/bug-report-api.ts b/lib/bug-report-api.ts
new file mode 100644
index 0000000..dbb9c90
--- /dev/null
+++ b/lib/bug-report-api.ts
@@ -0,0 +1,304 @@
+import "server-only";
+import { createHash, randomBytes, randomUUID } from "node:crypto";
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { admin, required, user } from "./supabase";
+import { accountProfile } from "./profile";
+import { requestOrigin } from "./request-origin";
+import { ServiceError } from "./service-error";
+import {
+ bugReportSchema,
+ bugMessageSchema,
+ type BugReportRecord,
+ type BugReportView,
+} from "../shared/bug-reports";
+import { supportScreenshot } from "./support-images";
+import { supportReady, syncBugAfterResponse } from "./support-sync";
+
+type Dependencies = {
+ jsonBody: (request: NextRequest) => Promise;
+ limit: (key: string, count: number, seconds: number) => Promise;
+};
+export async function bugReportApi(
+ request: NextRequest,
+ path: string[],
+ { jsonBody, limit }: Dependencies,
+) {
+ const db = admin(),
+ account = await user();
+ const profile = account ? await accountProfile(account) : null;
+ const saved = request.cookies.get("mg_support")?.value;
+ const secret = saved ?? randomBytes(32).toString("hex");
+ const hash = createHash("sha256").update(secret).digest("hex");
+ const respond = (body: unknown) => {
+ const response = NextResponse.json(body, {
+ headers: { "Cache-Control": "no-store" },
+ });
+ if (!saved)
+ response.cookies.set("mg_support", secret, {
+ httpOnly: true,
+ sameSite: "lax",
+ secure: requestOrigin(request, process.env.MINEGEO_URL).startsWith(
+ "https:",
+ ),
+ path: "/",
+ maxAge: 60 * 60 * 24 * 180,
+ });
+ return response;
+ };
+ const owns = (record: BugReportRecord) =>
+ record.owner_id
+ ? record.owner_id === account?.id
+ : !!saved && record.access_hash === hash;
+ async function owned(id: string) {
+ const record = required(
+ await db.from("bug_reports").select("*").eq("id", id).maybeSingle(),
+ );
+ if (!owns(record)) throw new ServiceError("Report not found", 404);
+ return record;
+ }
+ async function view(record: BugReportRecord): Promise {
+ const messages = required(
+ await db
+ .from("bug_report_messages")
+ .select("id,kind,body,author_name,delivered,created_at")
+ .eq("report_id", record.id)
+ .order("created_at", { ascending: false })
+ .limit(200),
+ ).reverse();
+ const screenshot = record.screenshot_path
+ ? required(
+ await db.storage
+ .from("minegeo-support")
+ .createSignedUrl(record.screenshot_path, 600),
+ ).signedUrl
+ : null;
+ return {
+ id: record.id,
+ title: record.title,
+ description: record.description,
+ page_path: record.page_path,
+ state: record.state,
+ linear_identifier: record.linear_identifier,
+ glow_requested: record.glow_requested,
+ created_at: record.created_at,
+ screenshot,
+ messages,
+ isGuest: record.owner_id === null,
+ authorAppearance: record.owner_id ? (profile ?? undefined) : undefined,
+ canClaim: record.owner_id === null && !!account,
+ };
+ }
+ if (path.length === 1 && request.method === "GET") {
+ const rows = required(
+ await db
+ .from("bug_reports")
+ .select("id,title,state,created_at,linear_identifier")
+ .or(
+ account
+ ? `owner_id.eq.${account.id},and(owner_id.is.null,access_hash.eq.${hash})`
+ : `and(owner_id.is.null,access_hash.eq.${hash})`,
+ )
+ .order("created_at", { ascending: false })
+ .limit(50),
+ );
+ return respond({
+ ready: supportReady(),
+ signedIn: !!account,
+ displayName: profile?.display_name ?? null,
+ nameGlow: profile?.name_glow ?? false,
+ reports: rows,
+ });
+ }
+ if (request.method === "POST" && path.length === 1) {
+ if (!saved)
+ throw new ServiceError("Open support before sending a report", 409);
+ const body = bugReportSchema.parse(await jsonBody(request));
+ const lookup = await db
+ .from("bug_reports")
+ .select("*")
+ .eq("client_id", body.clientId)
+ .maybeSingle();
+ if (lookup.error) required(lookup);
+ let record = lookup.data;
+ if (record && !owns(record))
+ throw new ServiceError("Report not found", 404);
+ if (!record) {
+ if (!supportReady())
+ throw new ServiceError(
+ "Bug reporting is temporarily unavailable. Please try again later.",
+ 503,
+ );
+ const ip =
+ request.headers.get("x-forwarded-for")?.split(",")[0].trim() ??
+ "unknown";
+ await limit(
+ "support:create:" + createHash("sha256").update(ip).digest("hex"),
+ 20,
+ 3600,
+ );
+ await limit("support:create:" + hash, 5, 3600);
+ const inserted = await db
+ .from("bug_reports")
+ .upsert(
+ {
+ id: randomUUID(),
+ client_id: body.clientId,
+ owner_id: account?.id ?? null,
+ access_hash: hash,
+ display_name:
+ profile?.display_name ?? body.displayName ?? "Guest explorer",
+ title: body.title,
+ description: body.description,
+ page_path: body.pagePath,
+ screenshot_expected: body.screenshot,
+ },
+ { onConflict: "client_id", ignoreDuplicates: true },
+ )
+ .select("id");
+ required(inserted);
+ record = required(
+ await db
+ .from("bug_reports")
+ .select("*")
+ .eq("client_id", body.clientId)
+ .single(),
+ );
+ if (!owns(record)) throw new ServiceError("Report not found", 404);
+ }
+ const upload =
+ record.state === "draft" && record.screenshot_expected
+ ? required(
+ await db.storage
+ .from("minegeo-support")
+ .createSignedUploadUrl(`${record.id}/input`, { upsert: true }),
+ ).signedUrl
+ : null;
+ return respond({ id: record.id, state: record.state, upload });
+ }
+ const id = z.string().uuid().parse(path[1]);
+ const record = await owned(id);
+ if (path.length === 2 && request.method === "GET") {
+ if (record.state !== "draft") syncBugAfterResponse(id);
+ return respond(await view(record));
+ }
+ if (path.length !== 3 || request.method !== "POST")
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ if (path[2] === "submit") {
+ if (record.state === "draft") {
+ let screenshotPath: string | null = null;
+ if (record.screenshot_expected) {
+ const image = required(
+ await db.storage.from("minegeo-support").download(`${id}/input`),
+ );
+ const normalized = await supportScreenshot(
+ new Uint8Array(await image.arrayBuffer()),
+ );
+ screenshotPath = `${id}/screenshot.webp`;
+ const uploaded = await db.storage
+ .from("minegeo-support")
+ .upload(screenshotPath, normalized, {
+ contentType: "image/webp",
+ upsert: false,
+ });
+ if (
+ uploaded.error &&
+ uploaded.error.message !== "The resource already exists"
+ )
+ throw new ServiceError(
+ "Screenshot upload failed. Please try again.",
+ 503,
+ );
+ }
+ // Stable initial message ID and conditional transition make submit retries safe.
+ required(
+ await db
+ .from("bug_report_messages")
+ .upsert(
+ {
+ id,
+ report_id: id,
+ kind: "user",
+ body: record.description,
+ author_name: record.display_name,
+ },
+ { onConflict: "id", ignoreDuplicates: true },
+ )
+ .select("id"),
+ );
+ required(
+ await db
+ .from("bug_reports")
+ .update({
+ state: "pending",
+ screenshot_path: screenshotPath,
+ updated_at: new Date().toISOString(),
+ })
+ .eq("id", id)
+ .eq("state", "draft")
+ .select("id"),
+ );
+ }
+ syncBugAfterResponse(id);
+ return respond(await view(await owned(id)));
+ }
+ if (path[2] === "messages") {
+ if (record.state === "draft")
+ throw new ServiceError("Submit this report before sending messages", 409);
+ const body = bugMessageSchema.parse(await jsonBody(request));
+ const existing = await db
+ .from("bug_report_messages")
+ .select("report_id,kind")
+ .eq("report_id", id)
+ .eq("client_id", body.clientId)
+ .maybeSingle();
+ if (existing.error) required(existing);
+ if (
+ existing.data &&
+ (existing.data.report_id !== id || existing.data.kind !== "user")
+ )
+ throw new ServiceError("Invalid support message", 400);
+ if (!existing.data) {
+ await limit("support:message:" + hash, 30, 60);
+ required(
+ await db
+ .from("bug_report_messages")
+ .upsert(
+ {
+ client_id: body.clientId,
+ report_id: id,
+ kind: "user",
+ body: body.text,
+ author_name: profile?.display_name ?? record.display_name,
+ },
+ { onConflict: "report_id,client_id", ignoreDuplicates: true },
+ )
+ .select("id"),
+ );
+ const inserted = required(
+ await db
+ .from("bug_report_messages")
+ .select("report_id,kind")
+ .eq("report_id", id)
+ .eq("client_id", body.clientId)
+ .single(),
+ );
+ if (inserted.report_id !== id || inserted.kind !== "user")
+ throw new ServiceError("Invalid support message", 400);
+ }
+ syncBugAfterResponse(id);
+ return respond(await view(record));
+ }
+ if (path[2] === "claim") {
+ if (!account) throw new ServiceError("Sign in to link this report", 401);
+ required(
+ await db.rpc("claim_bug_report", {
+ p_report: id,
+ p_hash: hash,
+ p_owner: account.id,
+ }),
+ );
+ return respond(await view(await owned(id)));
+ }
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+}
diff --git a/lib/catalog.ts b/lib/catalog.ts
index 7fd741d..7a41efd 100644
--- a/lib/catalog.ts
+++ b/lib/catalog.ts
@@ -26,6 +26,8 @@ export type MapListing = {
image?: string;
mapImage?: string;
credit?: string;
+ creditGlow?: boolean;
+ creditAppearance?: import("../shared/profile-appearance").ProfileAppearance;
};
export async function mediaUrl(path: string | null, expires = 3600) {
return (await mediaUrls([path], expires))[0];
@@ -61,7 +63,9 @@ export async function catalog() {
? required(
await client
.from("profiles")
- .select("id,display_name")
+ .select(
+ "id,display_name,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
+ )
.in("id", [...new Set(maps.map((m) => m.owner_id))]),
)
: [];
@@ -71,6 +75,8 @@ export async function catalog() {
return maps.map((m, index) => ({
...m,
credit: owners.find((p) => p.id === m.owner_id)?.display_name ?? "Explorer",
+ creditGlow: owners.find((p) => p.id === m.owner_id)?.name_glow ?? false,
+ creditAppearance: owners.find((p) => p.id === m.owner_id),
image: signed[index * 2] ?? undefined,
mapImage: signed[index * 2 + 1] ?? undefined,
server: servers.find((s) => s.id === m.server_id),
@@ -90,7 +96,9 @@ export async function getMap(id: string) {
const [owner, signed] = await Promise.all([
admin()
.from("profiles")
- .select("display_name")
+ .select(
+ "display_name,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
+ )
.eq("id", map.owner_id)
.maybeSingle(),
mediaUrls([map.cover_path, map.map_path]),
@@ -98,6 +106,8 @@ export async function getMap(id: string) {
return {
...map,
credit: owner.data?.display_name ?? "Explorer",
+ creditGlow: owner.data?.name_glow ?? false,
+ creditAppearance: owner.data ?? undefined,
image: signed[0],
mapImage: signed[1],
};
diff --git a/lib/leaderboard.ts b/lib/leaderboard.ts
index 3544c46..0cde27a 100644
--- a/lib/leaderboard.ts
+++ b/lib/leaderboard.ts
@@ -17,7 +17,7 @@ export async function leaderboard({
limit?: number;
} = {}): Promise {
try {
- return required(
+ const data = required(
await admin().rpc("player_leaderboard", {
p_difficulty: difficulty,
p_search: search,
@@ -25,6 +25,30 @@ export async function leaderboard({
p_limit: limit,
}),
) as unknown as LeaderboardData;
+ const ids = [
+ ...new Set(
+ [...data.entries, ...data.leaders].map((entry) => entry.player_id),
+ ),
+ ];
+ const profiles = ids.length
+ ? required(
+ await admin()
+ .from("profiles")
+ .select(
+ "id,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
+ )
+ .in("id", ids),
+ )
+ : [];
+ const addGlow = (entry: LeaderboardData["entries"][number]) => ({
+ ...entry,
+ ...profiles.find((profile) => profile.id === entry.player_id),
+ });
+ return {
+ ...data,
+ entries: data.entries.map(addGlow),
+ leaders: data.leaders.map(addGlow),
+ };
} catch (error) {
// A leaderboard outage must not take the map catalog or homepage offline.
console.error(
diff --git a/lib/linear-support-client.ts b/lib/linear-support-client.ts
new file mode 100644
index 0000000..14ade01
--- /dev/null
+++ b/lib/linear-support-client.ts
@@ -0,0 +1,168 @@
+import {
+ escapeSupportMarkdown,
+ type BugReportRecord,
+} from "../shared/bug-reports";
+
+type Issue = { id: string; identifier: string; team: { id: string } };
+export class LinearSupportClient {
+ constructor(
+ private key: string,
+ private team: string,
+ private project?: string,
+ private request: typeof fetch = fetch,
+ ) {}
+ async graphql(
+ query: string,
+ variables: object,
+ missing = false,
+ ): Promise {
+ const response = await this.request("https://api.linear.app/graphql", {
+ method: "POST",
+ headers: { Authorization: this.key, "Content-Type": "application/json" },
+ body: JSON.stringify({ query, variables }),
+ signal: AbortSignal.timeout(15_000),
+ });
+ const result = await response.json();
+ if (
+ missing &&
+ response.ok &&
+ result.errors?.length &&
+ result.errors?.every(
+ (error: { message?: string; extensions?: { code?: string } }) =>
+ error.extensions?.code === "ENTITY_NOT_FOUND" ||
+ /^Entity not found/i.test(error.message ?? ""),
+ )
+ )
+ return null;
+ if (!response.ok || result.errors?.length || !result.data)
+ throw new Error("Support delivery is temporarily unavailable");
+ return result.data;
+ }
+ async issue(id: string): Promise {
+ const data = await this.graphql<{ issue: Issue }>(
+ "query SupportIssue($id: String!) { issue(id: $id) { id identifier team { id } } }",
+ { id },
+ true,
+ );
+ const issue = data?.issue ?? null;
+ if (issue && issue.team.id !== this.team)
+ throw new Error("Support issue team mismatch");
+ return issue;
+ }
+ async createIssue(report: BugReportRecord): Promise {
+ const existing = await this.issue(report.id);
+ if (existing) return existing;
+ const description = [
+ "#user-report-message",
+ `Reporter: ${escapeSupportMarkdown(report.display_name)}`,
+ `Page: ${report.page_path}`,
+ "",
+ escapeSupportMarkdown(report.description),
+ "",
+ report.linear_asset_url
+ ? ``
+ : "",
+ "",
+ "Reply to the player with #player-report-send followed by your message.",
+ "Grant or remove the username reward with #player-report-glow grant or #player-report-glow revoke.",
+ `Report reference: ${report.id}`,
+ ].join("\n");
+ try {
+ const data = await this.graphql<{
+ issueCreate: { success: boolean; issue: Issue };
+ }>(
+ "mutation SupportIssueCreate($input: IssueCreateInput!) { issueCreate(input:$input) { success issue { id identifier team { id } } } }",
+ {
+ input: {
+ id: report.id,
+ teamId: this.team,
+ ...(this.project ? { projectId: this.project } : {}),
+ title: `[Player bug] ${report.title}`,
+ description,
+ priority: 3,
+ },
+ },
+ );
+ if (
+ !data?.issueCreate.success ||
+ data.issueCreate.issue?.id !== report.id
+ )
+ throw new Error("Support issue creation failed");
+ return data.issueCreate.issue;
+ } catch (error) {
+ // A timed-out creation may have succeeded. A stable UUID prevents a second issue.
+ const recovered = await this.issue(report.id);
+ if (recovered) return recovered;
+ throw error;
+ }
+ }
+ async comment(id: string, reportId: string, name: string, text: string) {
+ const lookup = () =>
+ this.graphql<{ comment: { id: string; issue: { id: string } } }>(
+ "query SupportComment($id:String!) { comment(id:$id) { id issue { id } } }",
+ { id },
+ true,
+ );
+ const existing = (await lookup())?.comment;
+ if (existing) {
+ if (existing.issue.id !== reportId)
+ throw new Error("Support comment mismatch");
+ return;
+ }
+ try {
+ const data = await this.graphql<{
+ commentCreate: { success: boolean; comment: { id: string } };
+ }>(
+ "mutation SupportCommentCreate($input:CommentCreateInput!) { commentCreate(input:$input) { success comment { id } } }",
+ {
+ input: {
+ id,
+ issueId: reportId,
+ body: `#user-report-message\n${escapeSupportMarkdown(name)}\n\n${escapeSupportMarkdown(text)}`,
+ },
+ },
+ );
+ if (!data?.commentCreate.success || data.commentCreate.comment?.id !== id)
+ throw new Error("Support comment creation failed");
+ } catch (error) {
+ const recovered = (await lookup())?.comment;
+ if (!recovered || recovered.issue.id !== reportId) throw error;
+ }
+ }
+ async upload(bytes: Uint8Array, filename: string) {
+ const data = await this.graphql<{
+ fileUpload: {
+ success: boolean;
+ uploadFile: {
+ uploadUrl: string;
+ assetUrl: string;
+ headers: { key: string; value: string }[];
+ };
+ };
+ }>(
+ 'mutation SupportFile($size:Int!,$filename:String!){ fileUpload(contentType:"image/webp",filename:$filename,size:$size) { success uploadFile { uploadUrl assetUrl headers { key value } } } }',
+ { filename, size: bytes.length },
+ );
+ const file = data?.fileUpload.uploadFile;
+ if (
+ !data?.fileUpload.success ||
+ !file ||
+ new URL(file.uploadUrl).protocol !== "https:" ||
+ new URL(file.assetUrl).protocol !== "https:"
+ )
+ throw new Error("Support screenshot upload failed");
+ const headers = new Headers({
+ "Content-Type": "image/webp",
+ "Cache-Control": "private, max-age=31536000",
+ });
+ file.headers.forEach(({ key, value }) => headers.set(key, value));
+ const result = await this.request(file.uploadUrl, {
+ method: "PUT",
+ headers,
+ body: Buffer.from(bytes),
+ signal: AbortSignal.timeout(15_000),
+ });
+ if (!result.ok) throw new Error("Support screenshot upload failed");
+ return file.assetUrl;
+ }
+}
diff --git a/lib/moderator-users-api.ts b/lib/moderator-users-api.ts
new file mode 100644
index 0000000..74e4803
--- /dev/null
+++ b/lib/moderator-users-api.ts
@@ -0,0 +1,60 @@
+import "server-only";
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { admin, required } from "./supabase";
+import { ServiceError, checkServiceError } from "./service-error";
+import { prefixUpdateSchema } from "@/shared/profile-appearance";
+
+export async function moderatorUsersApi(
+ request: NextRequest,
+ path: string[],
+ reviewer: string,
+ jsonBody: (request: NextRequest) => Promise,
+) {
+ const db = admin();
+ const columns =
+ "id,display_name,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon" as const;
+ const respond = (body: unknown) =>
+ NextResponse.json(body, { headers: { "Cache-Control": "no-store" } });
+ if (request.method === "GET" && path.length === 2) {
+ const search = (request.nextUrl.searchParams.get("q") ?? "")
+ .trim()
+ .slice(0, 64);
+ let query = db
+ .from("profiles")
+ .select(columns)
+ .order("display_name")
+ .order("id")
+ .limit(50);
+ if (search) {
+ if (z.string().uuid().safeParse(search).success)
+ query = query.eq("id", search);
+ else
+ query = query.ilike(
+ "display_name",
+ "%" + search.replace(/[\\%_]/g, "\\$&") + "%",
+ );
+ }
+ return respond({ users: required(await query) });
+ }
+ if (request.method === "PATCH" && path.length === 3) {
+ const id = z.string().uuid().parse(path[2]),
+ body = prefixUpdateSchema.parse(await jsonBody(request));
+ const updated = await db.rpc("set_user_prefix", {
+ p_reviewer: reviewer,
+ p_user: id,
+ p_prefix: body.prefix,
+ p_icon: body.icon,
+ });
+ checkServiceError(updated.error);
+ const result = await db
+ .from("profiles")
+ .select(columns)
+ .eq("id", id)
+ .maybeSingle();
+ if (result.error) required(result);
+ if (!result.data) throw new ServiceError("Player not found", 404);
+ return respond({ user: result.data });
+ }
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+}
diff --git a/lib/multiplayer-api.ts b/lib/multiplayer-api.ts
index 3f9b895..5528983 100644
--- a/lib/multiplayer-api.ts
+++ b/lib/multiplayer-api.ts
@@ -3,6 +3,8 @@ import { createHash, randomBytes } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { admin, required, user } from "./supabase";
+import { accountProfile } from "./profile";
+import { multiplayerAppearance } from "./multiplayer-appearance";
import { mediaUrls } from "./catalog";
import { requestOrigin } from "./request-origin";
import { ServiceError } from "./service-error";
@@ -13,6 +15,7 @@ import {
roomGuessSchema,
chatMessageSchema,
type RoomView,
+ type RoomChat,
} from "../shared/multiplayer";
import {
datasetSchema,
@@ -55,6 +58,7 @@ export async function multiplayerApi(
const body = createRoomSchema.parse(await jsonBody(request));
await limit("multiplayer:create:" + accessHash, 20, 3600);
const account = await user();
+ if (account) await accountProfile(account);
const id = required(
await client.rpc("create_map_room", {
p_dataset: body.datasetId,
@@ -65,6 +69,16 @@ export async function multiplayerApi(
p_owner: account?.id ?? null,
}),
);
+ if (account)
+ required(
+ await client
+ .from("multiplayer_players")
+ .update({ account_id: account.id })
+ .eq("room_id", id)
+ .eq("access_hash", accessHash)
+ .is("account_id", null)
+ .select("id"),
+ );
return respond({ id });
}
const id = z.string().uuid().parse(path[1]);
@@ -81,16 +95,25 @@ export async function multiplayerApi(
? chatMessageSchema.parse(await jsonBody(request))
: null;
if (body) await limit("multiplayer:chat:" + accessHash, 60, 60);
- return respond(
- required(
- await client.rpc("multiplayer_chat", {
- p_room: id,
- p_access_hash: accessHash,
- p_text: body?.text ?? null,
- p_client_id: body?.clientId ?? null,
- }),
- ),
+ const chat = required(
+ await client.rpc("multiplayer_chat", {
+ p_room: id,
+ p_access_hash: accessHash,
+ p_text: body?.text ?? null,
+ p_client_id: body?.clientId ?? null,
+ }),
+ ) as unknown as RoomChat;
+ const appearances = await multiplayerAppearance(
+ id,
+ chat.messages.map((message) => message.playerId),
);
+ return respond({
+ ...chat,
+ messages: chat.messages.map((message) => ({
+ ...message,
+ ...appearances.get(message.playerId),
+ })),
+ });
}
let payload: Record = {};
if (request.method === "POST" && path.length === 3) {
@@ -121,6 +144,32 @@ export async function multiplayerApi(
p_payload: payload,
}),
) as unknown as RoomView;
+ if (action === "join" && room.you) {
+ const account = await user();
+ if (account) {
+ await accountProfile(account);
+ required(
+ await client
+ .from("multiplayer_players")
+ .update({ account_id: account.id })
+ .eq("id", room.you)
+ .eq("room_id", id)
+ .eq("access_hash", accessHash)
+ .is("account_id", null)
+ .select("id"),
+ );
+ }
+ }
+ if (action !== "media") {
+ const appearances = await multiplayerAppearance(
+ id,
+ room.players.map((player) => player.id),
+ );
+ room.players = room.players.map((player) => ({
+ ...player,
+ ...appearances.get(player.id),
+ }));
+ }
if (action !== "media") return respond(room);
if (!room.you) throw new ServiceError("Join this room to play", 403);
if (["lobby", "closed"].includes(room.status))
diff --git a/lib/multiplayer-appearance.ts b/lib/multiplayer-appearance.ts
new file mode 100644
index 0000000..1f7939c
--- /dev/null
+++ b/lib/multiplayer-appearance.ts
@@ -0,0 +1,42 @@
+import "server-only";
+import { admin, required } from "./supabase";
+import type { ProfileAppearance } from "@/shared/profile-appearance";
+
+export async function multiplayerAppearance(room: string, ids: string[]) {
+ const db = admin(),
+ result = new Map();
+ if (!ids.length) return result;
+ const players = required(
+ await db
+ .from("multiplayer_players")
+ .select("id,account_id")
+ .eq("room_id", room)
+ .in("id", [...new Set(ids)]),
+ );
+ const accounts = [
+ ...new Set(
+ players.flatMap((player) =>
+ player.account_id ? [player.account_id] : [],
+ ),
+ ),
+ ];
+ if (!accounts.length) return result;
+ const profiles = required(
+ await db
+ .from("profiles")
+ .select(
+ "id,display_name,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
+ )
+ .in("id", accounts),
+ );
+ for (const player of players) {
+ const profile = profiles.find(
+ (profile) => profile.id === player.account_id,
+ );
+ if (profile) {
+ const { id, display_name, ...appearance } = profile;
+ result.set(player.id, { name: display_name, appearance });
+ }
+ }
+ return result;
+}
diff --git a/lib/profile.ts b/lib/profile.ts
index 3b1f2c9..e59a26e 100644
--- a/lib/profile.ts
+++ b/lib/profile.ts
@@ -20,7 +20,9 @@ export async function accountProfile(account: User) {
return required(
await admin()
.from("profiles")
- .select("id,display_name,role")
+ .select(
+ "id,display_name,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon",
+ )
.eq("id", account.id)
.single(),
);
diff --git a/lib/support-images.ts b/lib/support-images.ts
new file mode 100644
index 0000000..8725a23
--- /dev/null
+++ b/lib/support-images.ts
@@ -0,0 +1,28 @@
+import sharp from "sharp";
+import { screenshotLimit } from "../shared/bug-reports";
+
+export async function supportScreenshot(bytes: Uint8Array) {
+ if (!bytes.length || bytes.length > screenshotLimit)
+ throw new Error("Use a screenshot smaller than 5 MB");
+ const image = sharp(bytes, {
+ limitInputPixels: 24_000_000,
+ failOn: "error",
+ animated: false,
+ });
+ const meta = await image.metadata();
+ if (
+ !["png", "jpeg", "webp"].includes(meta.format ?? "") ||
+ (meta.pages ?? 1) !== 1
+ )
+ throw new Error("Choose a PNG, JPG, or WebP screenshot");
+ return image
+ .rotate()
+ .resize({
+ width: 2400,
+ height: 2400,
+ fit: "inside",
+ withoutEnlargement: true,
+ })
+ .webp({ quality: 85 })
+ .toBuffer();
+}
diff --git a/lib/support-signature.ts b/lib/support-signature.ts
new file mode 100644
index 0000000..56dadcd
--- /dev/null
+++ b/lib/support-signature.ts
@@ -0,0 +1,22 @@
+import { createHmac, timingSafeEqual } from "node:crypto";
+
+export function verifySupportWebhook(
+ raw: string,
+ signature: string | null,
+ secret: string,
+ now = Date.now(),
+) {
+ if (!secret || !signature || !/^[a-f0-9]{64}$/i.test(signature)) return false;
+ const expected = createHmac("sha256", secret).update(raw).digest();
+ if (!timingSafeEqual(expected, Buffer.from(signature, "hex"))) return false;
+ try {
+ const timestamp = JSON.parse(raw).webhookTimestamp;
+ return (
+ typeof timestamp === "number" &&
+ Number.isFinite(timestamp) &&
+ Math.abs(now - timestamp) <= 60_000
+ );
+ } catch {
+ return false;
+ }
+}
diff --git a/lib/support-sync.ts b/lib/support-sync.ts
new file mode 100644
index 0000000..ea2dcb8
--- /dev/null
+++ b/lib/support-sync.ts
@@ -0,0 +1,123 @@
+import "server-only";
+import { randomUUID } from "node:crypto";
+import { after } from "next/server";
+import { admin, required } from "./supabase";
+import { LinearSupportClient } from "./linear-support-client";
+
+export function supportReady() {
+ return !!(
+ process.env.LINEAR_API_KEY &&
+ process.env.LINEAR_TEAM_ID &&
+ process.env.LINEAR_WEBHOOK_SECRET
+ );
+}
+export async function syncBugReport(id: string) {
+ if (!supportReady()) return;
+ const db = admin(),
+ worker = randomUUID();
+ if (
+ !required(
+ await db.rpc("claim_bug_sync", { p_report: id, p_worker: worker }),
+ )
+ )
+ return;
+ try {
+ const linear = new LinearSupportClient(
+ process.env.LINEAR_API_KEY!,
+ process.env.LINEAR_TEAM_ID!,
+ process.env.LINEAR_PROJECT_ID,
+ );
+ const report = required(
+ await db.from("bug_reports").select("*").eq("id", id).single(),
+ );
+ if (!report.issue_synced) {
+ if (report.screenshot_path && !report.linear_asset_url) {
+ const image = required(
+ await db.storage
+ .from("minegeo-support")
+ .download(report.screenshot_path),
+ );
+ report.linear_asset_url = await linear.upload(
+ new Uint8Array(await image.arrayBuffer()),
+ `bug-${id}.webp`,
+ );
+ required(
+ await db
+ .from("bug_reports")
+ .update({ linear_asset_url: report.linear_asset_url })
+ .eq("id", id)
+ .eq("lease_owner", worker)
+ .select("id")
+ .single(),
+ );
+ }
+ const issue = await linear.createIssue(report);
+ required(
+ await db
+ .from("bug_reports")
+ .update({
+ issue_synced: true,
+ linear_identifier: issue.identifier,
+ state: "open",
+ })
+ .eq("id", id)
+ .eq("lease_owner", worker)
+ .select("id")
+ .single(),
+ );
+ }
+ required(
+ await db
+ .from("bug_report_messages")
+ .update({ delivered: true })
+ .eq("id", id)
+ .select("id"),
+ );
+ const messages = required(
+ await db
+ .from("bug_report_messages")
+ .select("*")
+ .eq("report_id", id)
+ .eq("kind", "user")
+ .eq("delivered", false)
+ .neq("id", id)
+ .order("created_at")
+ .limit(50),
+ );
+ for (const message of messages) {
+ await linear.comment(message.id, id, message.author_name, message.body);
+ required(
+ await db
+ .from("bug_report_messages")
+ .update({ delivered: true })
+ .eq("id", message.id)
+ .select("id"),
+ );
+ }
+ required(
+ await db
+ .from("bug_reports")
+ .update({ lease_owner: null, lease_until: null, retry_at: null })
+ .eq("id", id)
+ .eq("lease_owner", worker)
+ .select("id"),
+ );
+ } catch {
+ console.error("Bug report delivery paused; the saved report will retry.");
+ required(
+ await db
+ .from("bug_reports")
+ .update({
+ lease_owner: null,
+ lease_until: null,
+ retry_at: new Date(Date.now() + 60_000).toISOString(),
+ })
+ .eq("id", id)
+ .eq("lease_owner", worker)
+ .select("id"),
+ );
+ }
+}
+export function syncBugAfterResponse(id: string) {
+ after(() => syncBugReport(id));
+}
diff --git a/scripts/test-bug-reports-browser.mjs b/scripts/test-bug-reports-browser.mjs
new file mode 100644
index 0000000..b8f3da9
--- /dev/null
+++ b/scripts/test-bug-reports-browser.mjs
@@ -0,0 +1,286 @@
+import assert from "node:assert/strict";
+import { randomUUID, randomBytes } from "node:crypto";
+import { mkdir, writeFile } from "node:fs/promises";
+import { chromium } from "@playwright/test";
+import { createClient } from "@supabase/supabase-js";
+import sharp from "sharp";
+
+process.loadEnvFile(".env.local");
+const origin = process.argv[2] || "https://minegeo.vercel.app";
+const db = createClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL,
+ process.env.SUPABASE_SECRET_KEY,
+ { auth: { persistSession: false, autoRefreshToken: false } },
+);
+const browser = await chromium.launch({ headless: true });
+const context = await browser.newContext({ locale: "en-US" });
+const page = await context.newPage();
+page.setDefaultTimeout(30000);
+let reportId, accountId;
+const checked = (result) => {
+ if (result.error) throw new Error(result.error.message);
+ return result.data;
+};
+const statePath = ".data/support-deploy/fixture.json";
+async function phase(value) {
+ await writeFile(statePath, JSON.stringify({ reportId, phase: value }));
+ console.log("Support verification: " + value);
+}
+async function until(check, label, timeout = 120000) {
+ const end = Date.now() + timeout;
+ while (Date.now() < end) {
+ if (await check()) return;
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ }
+ throw new Error("Timed out: " + label);
+}
+async function json(path, body) {
+ const response =
+ body === undefined
+ ? await context.request.get(origin + "/api/v1/" + path)
+ : await context.request.post(origin + "/api/v1/" + path, {
+ headers: { origin },
+ data: body,
+ });
+ assert.equal(response.status(), 200, await response.text());
+ return response.json();
+}
+try {
+ await mkdir(".data/support-deploy", { recursive: true });
+ await page.goto(origin, { waitUntil: "networkidle" });
+ assert.equal(await page.locator(".header-actions > a").count(), 2);
+ await page.getByRole("link", { name: "Report a bug", exact: true }).click();
+ await page.getByLabel("Your player name").fill("Support verification");
+ await page
+ .getByLabel("Bug title", { exact: true })
+ .fill("[Verification] Player support " + randomUUID().slice(0, 8));
+ await page
+ .getByLabel("What happened?", { exact: true })
+ .fill(
+ "Temporary automated verification of the private support conversation and screenshot.",
+ );
+ const image = await sharp({
+ create: { width: 180, height: 100, channels: 3, background: "#8dce66" },
+ })
+ .png()
+ .toBuffer();
+ await page
+ .getByLabel("Screenshot (optional)")
+ .setInputFiles({
+ name: "support-test.png",
+ mimeType: "image/png",
+ buffer: image,
+ });
+ await page
+ .getByRole("button", { name: "Send bug report", exact: true })
+ .click();
+ await page.waitForURL(/\/support\/[0-9a-f-]{36}$/, { timeout: 60000 });
+ reportId = page.url().split("/").pop();
+ await phase("submitted");
+ await until(async () => {
+ const r = await json("bug-reports/" + reportId);
+ return r.state === "open" && r.messages[0]?.delivered;
+ }, "Linear issue creation");
+ const report = checked(
+ await db.from("bug_reports").select("*").eq("id", reportId).single(),
+ );
+ assert.ok(report.linear_identifier);
+ assert.match(report.linear_asset_url, /^https:/);
+ assert.ok(report.screenshot_path.endsWith("/screenshot.webp"));
+ assert.notEqual(report.client_id, report.id);
+ const retry = await json("bug-reports", {
+ clientId: report.client_id,
+ title: report.title,
+ description: report.description,
+ displayName: report.display_name,
+ pagePath: report.page_path,
+ screenshot: true,
+ });
+ assert.equal(retry.id, reportId);
+ assert.equal(retry.upload, null);
+ await Promise.all([
+ json("bug-reports/" + reportId + "/submit", {}),
+ json("bug-reports/" + reportId + "/submit", {}),
+ ]);
+ const stranger = await browser.newContext();
+ assert.equal(
+ (
+ await stranger.request.get(origin + "/api/v1/bug-reports/" + reportId)
+ ).status(),
+ 404,
+ );
+ assert.equal(
+ (
+ await stranger.request.post(origin + "/api/v1/bug-reports", {
+ headers: { origin: "https://foreign.example" },
+ data: {},
+ })
+ ).status(),
+ 403,
+ );
+ await stranger.close();
+ assert.equal(
+ (
+ await context.request.post(origin + "/api/support/linear", {
+ data: { type: "Comment" },
+ headers: { "linear-signature": "0".repeat(64) },
+ })
+ ).status(),
+ 401,
+ );
+ const anonymous = createClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL,
+ process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
+ { auth: { persistSession: false } },
+ );
+ assert.ok((await anonymous.from("bug_reports").select("id")).error);
+ const text = "Browser reply verification.\n#player-report-glow grant";
+ await page.getByLabel("Message support", { exact: true }).fill(text);
+ await page.getByRole("button", { name: "Send message", exact: true }).click();
+ await until(async () => {
+ const r = await json("bug-reports/" + reportId);
+ return r.messages.some((m) => m.body === text && m.delivered);
+ }, "player comment delivery");
+ const sent = checked(
+ await db
+ .from("bug_report_messages")
+ .select("client_id")
+ .eq("report_id", reportId)
+ .eq("body", text)
+ .single(),
+ );
+ await json("bug-reports/" + reportId + "/messages", {
+ clientId: sent.client_id,
+ text,
+ });
+ assert.equal(
+ checked(
+ await db
+ .from("bug_report_messages")
+ .select("id")
+ .eq("report_id", reportId)
+ .eq("body", text),
+ ).length,
+ 1,
+ );
+ const password = randomBytes(24).toString("base64url"),
+ email = "support-verify-" + randomUUID() + "@example.com";
+ const account = checked(
+ await db.auth.admin.createUser({
+ email,
+ password,
+ email_confirm: true,
+ user_metadata: { display_name: "Support verification" },
+ }),
+ );
+ accountId = account.user.id;
+ await json("auth", {
+ action: "login",
+ email,
+ password,
+ next: "/support/" + reportId,
+ });
+ await page.reload({ waitUntil: "networkidle" });
+ await page
+ .getByRole("button", { name: "Keep this report in my account" })
+ .click();
+ await until(
+ async () =>
+ checked(
+ await db
+ .from("bug_reports")
+ .select("owner_id")
+ .eq("id", reportId)
+ .single(),
+ ).owner_id === accountId,
+ "guest report claim",
+ );
+ await phase("ready-for-staff");
+ await until(
+ async () => {
+ const r = await json("bug-reports/" + reportId);
+ return (
+ r.glow_requested &&
+ r.messages.some(
+ (m) => m.kind === "staff" && m.body === "Verified support reply.",
+ )
+ );
+ },
+ "staff reply and glow",
+ 240000,
+ );
+ assert.equal(
+ checked(
+ await db
+ .from("profiles")
+ .select("name_glow")
+ .eq("id", accountId)
+ .single(),
+ ).name_glow,
+ true,
+ );
+ await page.goto(origin + "/settings", { waitUntil: "networkidle" });
+ await page
+ .locator(".username-glow")
+ .filter({ hasText: "Support verification" })
+ .waitFor();
+ await page.goto(origin + "/support/" + reportId, {
+ waitUntil: "networkidle",
+ });
+ await page.getByText("Verified support reply.", { exact: true }).waitFor();
+ await page.setViewportSize({ width: 390, height: 844 });
+ assert.ok(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth <= innerWidth + 1,
+ ),
+ "mobile overflow",
+ );
+ await page.screenshot({
+ path: ".data/support-deploy/conversation-mobile.png",
+ fullPage: true,
+ });
+ const view = await json("bug-reports/" + reportId);
+ assert.equal(
+ view.messages.filter((m) => m.kind === "staff").length,
+ 1,
+ "internal comments stay internal",
+ );
+ await phase("ready-for-revoke");
+ await until(
+ async () =>
+ !checked(
+ await db
+ .from("profiles")
+ .select("name_glow")
+ .eq("id", accountId)
+ .single(),
+ ).name_glow,
+ "reward revoke",
+ 240000,
+ );
+ await page.goto(origin + "/settings", { waitUntil: "networkidle" });
+ assert.equal(await page.locator(".username-glow").count(), 0);
+ await phase("passed");
+ console.log(
+ "Production support passed: screenshot, one issue, retries, private access, player reply, account linking, staff reply, real glow grant/revoke, mobile UI.",
+ );
+} catch (error) {
+ console.error(error.message);
+ process.exitCode = 1;
+ await phase("failed");
+} finally {
+ // Only records created by this verification are removed.
+ if (reportId) {
+ checked(
+ await db.storage
+ .from("minegeo-support")
+ .remove([reportId + "/input", reportId + "/screenshot.webp"]),
+ );
+ checked(await db.from("bug_reports").delete().eq("id", reportId));
+ }
+ if (accountId) {
+ checked(await db.from("profiles").delete().eq("id", accountId));
+ checked(await db.auth.admin.deleteUser(accountId));
+ }
+ await browser.close();
+}
diff --git a/scripts/test-bug-reports-sql.mjs b/scripts/test-bug-reports-sql.mjs
new file mode 100644
index 0000000..c3b0dac
--- /dev/null
+++ b/scripts/test-bug-reports-sql.mjs
@@ -0,0 +1,259 @@
+import assert from "node:assert/strict";
+import { randomUUID } from "node:crypto";
+import { readFile, readdir } from "node:fs/promises";
+import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js";
+const db = new PGlite();
+try {
+ await db.exec(`create role anon;create role authenticated;create role service_role;
+ create schema auth;create table auth.users(id uuid primary key);
+ create function auth.uid() returns uuid language sql as $$select null::uuid$$;
+ create schema storage;create table storage.buckets(id text primary key,name text,public boolean,file_size_limit bigint);`);
+ for (const file of (await readdir("supabase/migrations"))
+ .filter((file) => file.endsWith(".sql"))
+ .sort())
+ await db.exec(await readFile(`supabase/migrations/${file}`, "utf8"));
+ const account = randomUUID();
+ await db.query("insert into auth.users values($1)", [account]);
+ await db.query(
+ "insert into public.profiles(id,display_name) values($1,'Support fixture')",
+ [account],
+ );
+ const hash = "a".repeat(64),
+ nonce = randomUUID();
+ const report = (
+ await db.query(
+ "insert into public.bug_reports(client_id,access_hash,display_name,title,description,state) values($1,$2,'Guest','Test issue','A valid test bug description.','pending') returning id",
+ [nonce, hash],
+ )
+ ).rows[0].id;
+ assert.notEqual(
+ report,
+ nonce,
+ "Linear issue IDs are server generated, not chosen by the player",
+ );
+ const rpc = async (sql, params) => (await db.query(sql, params)).rows[0];
+ assert.equal(
+ (await rpc("select public.claim_bug_sync($1,'worker-a') as ok", [report]))
+ .ok,
+ true,
+ );
+ assert.equal(
+ (await rpc("select public.claim_bug_sync($1,'worker-b') as ok", [report]))
+ .ok,
+ false,
+ );
+ await db.query(
+ "update public.bug_reports set lease_until=now()-interval '1 second' where id=$1",
+ [report],
+ );
+ assert.equal(
+ (await rpc("select public.claim_bug_sync($1,'worker-b') as ok", [report]))
+ .ok,
+ true,
+ );
+ const command = randomUUID();
+ await db.query("select public.receive_bug_reply($1,$2,'Staff','',true)", [
+ report,
+ command,
+ ]);
+ assert.equal(
+ (
+ await db.query(
+ "select glow_requested from public.bug_reports where id=$1",
+ [report],
+ )
+ ).rows[0].glow_requested,
+ true,
+ );
+ await assert.rejects(
+ db.query("select public.claim_bug_report($1,$2,$3)", [
+ report,
+ "b".repeat(64),
+ account,
+ ]),
+ /Report not found/,
+ );
+ await db.query("select public.claim_bug_report($1,$2,$3)", [
+ report,
+ hash,
+ account,
+ ]);
+ assert.equal(
+ (
+ await db.query("select name_glow from public.profiles where id=$1", [
+ account,
+ ])
+ ).rows[0].name_glow,
+ true,
+ );
+ await db.query("select public.receive_bug_reply($1,$2,'Staff','',false)", [
+ report,
+ randomUUID(),
+ ]);
+ await db.query("select public.receive_bug_reply($1,$2,'Staff','',true)", [
+ report,
+ command,
+ ]);
+ assert.equal(
+ (
+ await db.query("select name_glow from public.profiles where id=$1", [
+ account,
+ ])
+ ).rows[0].name_glow,
+ false,
+ "old duplicate grant cannot override a revoke",
+ );
+ await db.query(
+ "select public.receive_bug_reply($1,$2,'Staff','We found the bug.',null)",
+ [report, randomUUID()],
+ );
+ assert.equal(
+ (
+ await db.query(
+ "select count(*)::integer as n from public.bug_report_messages where report_id=$1",
+ [report],
+ )
+ ).rows[0].n,
+ 3,
+ );
+ for (const role of ["anon", "authenticated"]) {
+ await db.exec(`set role ${role}`);
+ for (const table of ["bug_reports", "bug_report_messages"])
+ await assert.rejects(
+ db.query(`select * from public.${table}`),
+ /permission denied/,
+ );
+ await assert.rejects(
+ db.query("select public.receive_bug_reply($1,$2,'Spoof','Fake',true)", [
+ report,
+ randomUUID(),
+ ]),
+ /permission denied/,
+ );
+ await assert.rejects(
+ db.query("select public.claim_bug_report($1,$2,$3)", [
+ report,
+ hash,
+ account,
+ ]),
+ /permission denied/,
+ );
+ await db.exec("reset role");
+ }
+ await db.exec(
+ "create role reward_probe bypassrls;grant update on public.profiles to reward_probe;grant select on public.profiles to reward_probe;set role reward_probe;",
+ );
+ await assert.rejects(
+ db.query("update public.profiles set name_glow=true where id=$1", [
+ account,
+ ]),
+ /Reward permission/,
+ );
+ await db.exec("reset role");
+ assert.equal(
+ (
+ await db.query("select bug_finder from public.profiles where id=$1", [
+ account,
+ ])
+ ).rows[0].bug_finder,
+ true,
+ "revoking glow keeps the confirmed bug-finder badge",
+ );
+ await assert.rejects(
+ db.query("update public.profiles set glow_color='#123456' where id=$1", [
+ account,
+ ]),
+ /Earn a username glow/,
+ );
+ const mod = randomUUID();
+ await db.query("insert into auth.users values($1)", [mod]);
+ await db.query(
+ "insert into public.profiles(id,role) values($1,'moderator')",
+ [mod],
+ );
+ await assert.rejects(
+ db.query("select public.set_user_prefix($1,$2,'Builder','gem')", [
+ account,
+ mod,
+ ]),
+ /Moderator permission/,
+ );
+ await db.query("select public.set_user_prefix($1,$2,'Builder','gem')", [
+ mod,
+ account,
+ ]);
+ assert.equal(
+ (
+ await db.query("select custom_prefix from public.profiles where id=$1", [
+ account,
+ ])
+ ).rows[0].custom_prefix,
+ "Builder",
+ );
+ await db.query("select public.set_user_prefix($1,$2,null,'star')", [
+ mod,
+ account,
+ ]);
+ assert.equal(
+ (
+ await db.query("select custom_prefix from public.profiles where id=$1", [
+ account,
+ ])
+ ).rows[0].custom_prefix,
+ null,
+ );
+ await db.exec("set role reward_probe");
+ for (const column of [
+ "role='moderator'",
+ "bug_finder=false",
+ "custom_prefix='Spoof'",
+ "custom_prefix_icon='crown'",
+ ])
+ await assert.rejects(
+ db.query(`update public.profiles set ${column} where id=$1`, [account]),
+ /Moderator permission/,
+ );
+ await assert.rejects(
+ db.query("select public.set_user_prefix($1,$2,'Spoof','star')", [
+ mod,
+ account,
+ ]),
+ /permission denied/,
+ );
+ await db.exec("reset role");
+ const guest = (
+ await db.query(
+ "insert into public.bug_reports(client_id,access_hash,display_name,title,description,state) values($1,$2,'Guest','Guest verification','A guest verification description.','pending') returning id",
+ [randomUUID(), hash],
+ )
+ ).rows[0].id;
+ await db.query("select public.receive_bug_reply($1,$2,'Staff','',true)", [
+ guest,
+ randomUUID(),
+ ]);
+ await db.query("select public.receive_bug_reply($1,$2,'Staff','',false)", [
+ guest,
+ randomUUID(),
+ ]);
+ await db.query("select public.claim_bug_report($1,$2,$3)", [
+ guest,
+ hash,
+ mod,
+ ]);
+ const claimed = (
+ await db.query(
+ "select bug_finder,name_glow from public.profiles where id=$1",
+ [mod],
+ )
+ ).rows[0];
+ assert.equal(claimed.bug_finder, true);
+ assert.equal(claimed.name_glow, false);
+ console.log(
+ "Bug report SQL passed: private tables, lease retries, guest claims, staff reply deduplication, grant/revoke rewards and privilege isolation.",
+ );
+} catch (error) {
+ console.error(error.message);
+ process.exitCode = 1;
+} finally {
+ await db.close();
+}
diff --git a/scripts/test-profile-appearance-browser.mjs b/scripts/test-profile-appearance-browser.mjs
new file mode 100644
index 0000000..a25f817
--- /dev/null
+++ b/scripts/test-profile-appearance-browser.mjs
@@ -0,0 +1,382 @@
+import assert from "node:assert/strict";
+import { randomUUID, randomBytes } from "node:crypto";
+import { mkdir, writeFile } from "node:fs/promises";
+import { chromium } from "@playwright/test";
+import { createClient } from "@supabase/supabase-js";
+
+process.loadEnvFile(".env.local");
+const origin = process.argv[2] || "https://minegeo.vercel.app",
+ reportId = process.argv[3];
+assert.match(
+ reportId ?? "",
+ /^[0-9a-f-]{36}$/,
+ "Pass a disposable Linear test issue UUID for webhook verification",
+);
+const db = createClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL,
+ process.env.SUPABASE_SECRET_KEY,
+ { auth: { persistSession: false, autoRefreshToken: false } },
+);
+const browser = await chromium.launch({ headless: true }),
+ accounts = [],
+ contexts = [];
+let roomId,
+ createdReport = false;
+const checked = (result) => {
+ if (result.error) throw new Error(result.error.message);
+ return result.data;
+};
+const phase = async (value) => {
+ await writeFile(
+ ".data/support-deploy/appearance-fixture.json",
+ JSON.stringify({ reportId, phase: value }),
+ );
+ console.log("Appearance verification: " + value);
+};
+async function until(check, label, timeout = 120000) {
+ const end = Date.now() + timeout;
+ while (Date.now() < end) {
+ if (await check()) return;
+ await new Promise((resolve) => setTimeout(resolve, 1500));
+ }
+ throw new Error("Timed out: " + label);
+}
+async function api(
+ context,
+ path,
+ body,
+ method = body === undefined ? "GET" : "POST",
+ status = 200,
+) {
+ const response = await context.request.fetch(origin + "/api/v1/" + path, {
+ method,
+ headers: { origin },
+ ...(body === undefined ? {} : { data: body }),
+ });
+ assert.equal(
+ response.status(),
+ status,
+ path + ": " + (await response.text()),
+ );
+ return response.json();
+}
+async function account(role) {
+ const email = "appearance-" + randomUUID() + "@example.com",
+ password = randomBytes(24).toString("base64url");
+ const result = checked(
+ await db.auth.admin.createUser({
+ email,
+ password,
+ email_confirm: true,
+ user_metadata: {
+ display_name:
+ role === "moderator" ? "Prefix moderator test" : "Badge player test",
+ },
+ }),
+ );
+ const id = result.user.id;
+ accounts.push(id);
+ checked(
+ await db.from("profiles").insert({
+ id,
+ role,
+ display_name:
+ role === "moderator" ? "Prefix moderator test" : "Badge player test",
+ }),
+ );
+ const context = await browser.newContext({ locale: "en-US" });
+ contexts.push(context);
+ await api(context, "auth", {
+ action: "login",
+ email,
+ password,
+ next: "/settings",
+ });
+ return { id, context, page: await context.newPage() };
+}
+try {
+ await mkdir(".data/support-deploy", { recursive: true });
+ await phase("starting");
+ const player = await account("contributor"),
+ mod = await account("moderator");
+ await api(player.context, "profile", { glowColor: "#54baff" }, "PATCH", 403);
+ await api(
+ player.context,
+ "profile",
+ { custom_prefix: "Spoof" },
+ "PATCH",
+ 400,
+ );
+ await api(player.context, "moderation/users", undefined, "GET", 403);
+ await api(
+ player.context,
+ "moderation/users/" + player.id,
+ { prefix: "Spoof", icon: "star" },
+ "PATCH",
+ 403,
+ );
+ await player.page.goto(origin + "/settings", { waitUntil: "networkidle" });
+ assert.equal(await player.page.locator("input[type=color]").count(), 0);
+ await mod.page.goto(origin + "/settings", { waitUntil: "networkidle" });
+ assert.ok((await mod.page.locator(".username-moderator svg").count()) > 0);
+ await mod.page.goto(origin + "/moderation", { waitUntil: "networkidle" });
+ await mod.page
+ .getByLabel("Player name or account ID", { exact: true })
+ .fill(player.id);
+ await mod.page
+ .getByRole("button", { name: "Find players", exact: true })
+ .click();
+ await mod.page
+ .locator(".prefix-player-list button")
+ .filter({ hasText: player.id })
+ .click();
+ await mod.page
+ .getByLabel("Custom prefix", { exact: true })
+ .fill("Cartographer");
+ await mod.page.getByLabel("Prefix icon", { exact: true }).selectOption("gem");
+ await mod.page
+ .getByRole("button", { name: "Save prefix", exact: true })
+ .click();
+ await until(
+ async () => {
+ const saved = await mod.page
+ .getByText("Player prefix saved.", { exact: true })
+ .count();
+ const alerts = await mod.page.locator("[role=alert]").allTextContents();
+ const visibleErrors = alerts.filter((message) => message.trim());
+ if (visibleErrors.length)
+ throw new Error("Prefix save: " + visibleErrors.join(" | "));
+ return saved > 0;
+ },
+ "prefix save",
+ 30000,
+ );
+ await player.page.reload({ waitUntil: "networkidle" });
+ assert.ok(
+ (await player.page
+ .locator(".username-custom-prefix")
+ .filter({ hasText: "Cartographer" })
+ .count()) > 0,
+ );
+ const current = checked(
+ await db.from("profiles").select("*").eq("id", player.id).single(),
+ );
+ assert.equal(current.role, "contributor");
+ assert.equal(current.name_glow, false);
+ assert.equal(current.bug_finder, false);
+ checked(
+ await db.from("bug_reports").insert({
+ id: reportId,
+ client_id: randomUUID(),
+ owner_id: player.id,
+ access_hash: randomBytes(32).toString("hex"),
+ display_name: "Badge player test",
+ title: "Appearance verification",
+ description:
+ "Temporary test report for real webhook badge and glow verification.",
+ state: "open",
+ issue_synced: true,
+ linear_identifier: "Verification",
+ }),
+ );
+ createdReport = true;
+ await phase("ready-for-grant");
+ await until(
+ async () => {
+ const row = checked(
+ await db
+ .from("profiles")
+ .select("name_glow,bug_finder")
+ .eq("id", player.id)
+ .single(),
+ );
+ return row.name_glow && row.bug_finder;
+ },
+ "real webhook bug finder grant",
+ 240000,
+ );
+ await player.page.reload({ waitUntil: "networkidle" });
+ await player.page.locator("input[type=color]").waitFor();
+ assert.ok(
+ (await player.page.locator(".username-bug-finder svg").count()) > 0,
+ );
+ await player.page.locator("input[type=color]").evaluate((input) => {
+ Object.getOwnPropertyDescriptor(
+ HTMLInputElement.prototype,
+ "value",
+ ).set.call(input, "#54baff");
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ });
+ await player.page
+ .getByRole("button", { name: "Save glow color", exact: true })
+ .click();
+ await player.page.getByText("Glow color saved.", { exact: true }).waitFor();
+ await player.page.reload({ waitUntil: "networkidle" });
+ assert.equal(
+ await player.page.locator("input[type=color]").inputValue(),
+ "#54baff",
+ );
+ assert.equal(
+ await player.page
+ .locator(".account-page-heading .username-glow")
+ .evaluate((element) => getComputedStyle(element).color),
+ "rgb(84, 186, 255)",
+ );
+ const map = checked(
+ await db.from("datasets").select("id").eq("state", "published").limit(1),
+ )[0];
+ assert.ok(map);
+ roomId = (
+ await api(player.context, "rooms", {
+ datasetId: map.id,
+ difficulty: "easy",
+ displayName: "Temporary host alias",
+ playerLimit: 3,
+ })
+ ).id;
+ const guest = await browser.newContext({ locale: "en-US" });
+ contexts.push(guest);
+ await api(guest, "rooms/" + roomId + "/join", {
+ displayName: "Badge player test",
+ });
+ await api(mod.context, "rooms/" + roomId + "/join", {
+ displayName: "Temporary mod alias",
+ });
+ const room = await api(player.context, "rooms/" + roomId);
+ const host = room.players.find((p) => p.id === room.hostId);
+ assert.equal(host.name, "Badge player test");
+ assert.equal(host.appearance.glow_color, "#54baff");
+ assert.equal(host.appearance.bug_finder, true);
+ assert.equal(host.appearance.custom_prefix, "Cartographer");
+ const guestView = await api(guest, "rooms/" + roomId),
+ guestPlayer = guestView.players.find((p) => p.id === guestView.you);
+ assert.equal(
+ guestPlayer.appearance,
+ undefined,
+ "copying a name cannot copy a badge",
+ );
+ await api(player.context, "rooms/" + roomId + "/chat", {
+ clientId: randomUUID(),
+ text: "Verified colored badge in chat.",
+ });
+ const chat = await api(guest, "rooms/" + roomId + "/chat");
+ assert.equal(chat.messages[0].appearance.bug_finder, true);
+ assert.equal(chat.messages[0].appearance.custom_prefix, "Cartographer");
+ await player.page.goto(origin + "/multiplayer/" + roomId, {
+ waitUntil: "networkidle",
+ });
+ await player.page.getByRole("button", { name: /^Chat/ }).click();
+ await player.page
+ .getByText("Verified colored badge in chat.", { exact: true })
+ .waitFor();
+ assert.ok(
+ (await player.page
+ .locator(".room-chat-log .username-bug-finder svg")
+ .count()) > 0,
+ );
+ await player.page.setViewportSize({ width: 390, height: 844 });
+ assert.ok(
+ await player.page.evaluate(
+ () => document.documentElement.scrollWidth <= innerWidth + 1,
+ ),
+ "mobile roster overflow",
+ );
+ await player.page.screenshot({
+ path: ".data/support-deploy/appearance-multiplayer-mobile.png",
+ fullPage: true,
+ });
+ await player.page.goto(origin + "/settings", { waitUntil: "networkidle" });
+ assert.ok(
+ await player.page.evaluate(
+ () => document.documentElement.scrollWidth <= innerWidth + 1,
+ ),
+ "mobile settings overflow",
+ );
+ await player.page.screenshot({
+ path: ".data/support-deploy/appearance-settings-mobile.png",
+ fullPage: true,
+ });
+ await mod.page
+ .getByRole("button", { name: "Remove prefix", exact: true })
+ .click();
+ await until(
+ async () =>
+ checked(
+ await db
+ .from("profiles")
+ .select("custom_prefix")
+ .eq("id", player.id)
+ .single(),
+ ).custom_prefix === null,
+ "prefix removal",
+ );
+ await player.page.reload({ waitUntil: "networkidle" });
+ assert.equal(await player.page.locator(".username-custom-prefix").count(), 0);
+ assert.ok((await player.page.locator(".username-bug-finder").count()) > 0);
+ await phase("ready-for-revoke");
+ await until(
+ async () =>
+ !checked(
+ await db
+ .from("profiles")
+ .select("name_glow")
+ .eq("id", player.id)
+ .single(),
+ ).name_glow,
+ "real webhook revoke",
+ 240000,
+ );
+ await player.page.reload({ waitUntil: "networkidle" });
+ assert.equal(await player.page.locator("input[type=color]").count(), 0);
+ assert.equal(await player.page.locator(".username-glow").count(), 0);
+ assert.ok((await player.page.locator(".username-bug-finder").count()) > 0);
+ assert.equal(
+ checked(
+ await db
+ .from("profiles")
+ .select("glow_color")
+ .eq("id", player.id)
+ .single(),
+ ).glow_color,
+ "#54baff",
+ );
+ await phase("passed");
+ console.log(
+ "Profile appearance production passed: moderator-only prefixes, removal, automatic badges, real webhook grant/revoke, persistent color, authenticated multiplayer/chat, guest spoof prevention and mobile layouts.",
+ );
+} catch (error) {
+ for (const context of contexts)
+ for (const page of context.pages())
+ if (page.url().includes("/moderation")) {
+ await page
+ .screenshot({
+ path: ".data/support-deploy/appearance-failure.png",
+ fullPage: true,
+ })
+ .catch(() => {});
+ console.error(
+ "Moderator alerts: " +
+ JSON.stringify(
+ await page.locator("[role=alert]").allTextContents(),
+ ),
+ );
+ }
+ console.error(error.message);
+ process.exitCode = 1;
+ await phase("failed");
+} finally {
+ if (roomId)
+ checked(await db.from("multiplayer_rooms").delete().eq("id", roomId));
+ if (createdReport)
+ checked(await db.from("bug_reports").delete().eq("id", reportId));
+ for (const context of contexts)
+ await context.request
+ .post(origin + "/api/v1/auth/logout", { headers: { origin }, data: {} })
+ .catch(() => {});
+ for (const id of accounts) {
+ checked(await db.from("profiles").delete().eq("id", id));
+ checked(await db.auth.admin.deleteUser(id));
+ }
+ await browser.close();
+}
diff --git a/shared/account.ts b/shared/account.ts
index ced0465..24136d7 100644
--- a/shared/account.ts
+++ b/shared/account.ts
@@ -1,4 +1,5 @@
import { z } from "zod";
+import { glowColorSchema } from "./profile-appearance";
export const displayNameSchema = z
.string()
@@ -38,8 +39,15 @@ export const authActionSchema = z.discriminatedUnion("action", [
z.object({ action: z.literal("recover"), email }).strict(),
]);
export const profileUpdateSchema = z
- .object({ displayName: displayNameSchema })
- .strict();
+ .object({
+ displayName: displayNameSchema.optional(),
+ glowColor: glowColorSchema.optional(),
+ })
+ .strict()
+ .refine(
+ (value) => value.displayName !== undefined || value.glowColor !== undefined,
+ "Choose a profile change",
+ );
export const initialPasswordSchema = z
.object({ password: newPasswordSchema })
.strict();
@@ -61,10 +69,12 @@ export function authDestination(value: string | null | undefined): string {
value === "/settings" ||
value === "/dashboard" ||
value === "/servers" ||
+ value === "/support" ||
value === "/auth/reset-password"
)
return value;
if (value && /^\/dashboard\/[0-9a-f-]{36}$/i.test(value)) return value;
+ if (value && /^\/support\/[0-9a-f-]{36}$/i.test(value)) return value;
if (value && /^\/pair\?code=[A-F0-9]{8}$/.test(value)) return value;
return "/dashboard";
}
diff --git a/shared/bug-reports.ts b/shared/bug-reports.ts
new file mode 100644
index 0000000..4ecbe46
--- /dev/null
+++ b/shared/bug-reports.ts
@@ -0,0 +1,103 @@
+import { z } from "zod";
+import { displayNameSchema } from "./account";
+
+export const screenshotLimit = 5 * 1024 * 1024;
+const text = (min: number, max: number) =>
+ z
+ .string()
+ .trim()
+ .min(min)
+ .max(max)
+ .refine(
+ (value) =>
+ !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(
+ value,
+ ),
+ "Remove hidden control characters",
+ );
+export const bugReportSchema = z
+ .object({
+ clientId: z.string().uuid(),
+ title: text(5, 120),
+ description: text(15, 6000),
+ displayName: displayNameSchema.optional(),
+ pagePath: z
+ .string()
+ .max(300)
+ .regex(/^\/(?!\/)[a-zA-Z0-9/_-]*$/)
+ .default("/"),
+ screenshot: z.boolean().default(false),
+ })
+ .strict();
+export const bugMessageSchema = z
+ .object({ clientId: z.string().uuid(), text: text(1, 6000) })
+ .strict();
+export type BugReportRecord = {
+ id: string;
+ client_id: string;
+ owner_id: string | null;
+ access_hash: string;
+ display_name: string;
+ title: string;
+ description: string;
+ page_path: string;
+ state: "draft" | "pending" | "open";
+ screenshot_expected: boolean;
+ screenshot_path: string | null;
+ linear_asset_url: string | null;
+ linear_identifier: string | null;
+ issue_synced: boolean;
+ glow_requested: boolean;
+ bug_confirmed?: boolean;
+ lease_owner: string | null;
+ lease_until: string | null;
+ retry_at: string | null;
+ created_at: string;
+ updated_at: string;
+};
+export type BugMessageRecord = {
+ id: string;
+ client_id: string | null;
+ report_id: string;
+ kind: "user" | "staff" | "system";
+ body: string;
+ author_name: string;
+ delivered: boolean;
+ created_at: string;
+ external_id: string | null;
+};
+export type BugReportView = Pick<
+ BugReportRecord,
+ | "id"
+ | "title"
+ | "description"
+ | "page_path"
+ | "state"
+ | "linear_identifier"
+ | "glow_requested"
+ | "created_at"
+> & {
+ screenshot: string | null;
+ isGuest: boolean;
+ authorAppearance?: import("./profile-appearance").ProfileAppearance;
+ canClaim: boolean;
+ messages: Pick<
+ BugMessageRecord,
+ "id" | "kind" | "body" | "author_name" | "delivered" | "created_at"
+ >[];
+};
+export type SupportCommand =
+ | { kind: "reply"; text: string }
+ | { kind: "glow"; enabled: boolean };
+export function supportCommand(body: string): SupportCommand | null {
+ const reply = /^#player-report-send\s+([\s\S]+)$/.exec(body.trim());
+ if (reply) {
+ const parsed = text(1, 6000).safeParse(reply[1]);
+ return parsed.success ? { kind: "reply", text: parsed.data } : null;
+ }
+ const glow = /^#player-report-glow (grant|revoke)$/.exec(body.trim());
+ return glow ? { kind: "glow", enabled: glow[1] === "grant" } : null;
+}
+export function escapeSupportMarkdown(value: string) {
+ return value.replace(/[\\`*_{}\[\]()<>#!|~]/g, "\\$&");
+}
diff --git a/shared/database.ts b/shared/database.ts
index f53f4c7..64be178 100644
--- a/shared/database.ts
+++ b/shared/database.ts
@@ -43,6 +43,30 @@ export type Database = {
};
public: {
Tables: {
+ bug_reports: {
+ Row: import("./bug-reports").BugReportRecord;
+ Insert: Partial &
+ Pick<
+ import("./bug-reports").BugReportRecord,
+ | "client_id"
+ | "access_hash"
+ | "display_name"
+ | "title"
+ | "description"
+ >;
+ Update: Partial;
+ Relationships: [];
+ };
+ bug_report_messages: {
+ Row: import("./bug-reports").BugMessageRecord;
+ Insert: Partial &
+ Pick<
+ import("./bug-reports").BugMessageRecord,
+ "report_id" | "kind" | "body" | "author_name"
+ >;
+ Update: Partial;
+ Relationships: [];
+ };
multiplayer_messages: {
Row: {
id: number;
@@ -115,6 +139,7 @@ export type Database = {
};
multiplayer_players: {
Row: {
+ account_id: string | null;
id: string;
room_id: string;
access_hash: string;
@@ -124,6 +149,7 @@ export type Database = {
joined_at: string;
};
Insert: {
+ account_id?: string | null;
id?: string;
room_id: string;
access_hash: string;
@@ -132,7 +158,11 @@ export type Database = {
has_left?: boolean;
joined_at?: string;
};
- Update: { ready_round?: number; has_left?: boolean };
+ Update: {
+ ready_round?: number;
+ has_left?: boolean;
+ account_id?: string | null;
+ };
Relationships: [];
};
multiplayer_guesses: {
@@ -453,18 +483,33 @@ export type Database = {
};
profiles: {
Row: {
+ glow_color: string;
+ bug_finder: boolean;
+ custom_prefix: string | null;
+ custom_prefix_icon: string;
+ name_glow: boolean;
created_at: string;
display_name: string;
id: string;
role: string;
};
Insert: {
+ glow_color?: string;
+ bug_finder?: boolean;
+ custom_prefix?: string | null;
+ custom_prefix_icon?: string;
+ name_glow?: boolean;
created_at?: string;
display_name?: string;
id: string;
role?: string;
};
Update: {
+ glow_color?: string;
+ bug_finder?: boolean;
+ custom_prefix?: string | null;
+ custom_prefix_icon?: string;
+ name_glow?: boolean;
created_at?: string;
display_name?: string;
id?: string;
@@ -650,6 +695,33 @@ export type Database = {
[_ in never]: never;
};
Functions: {
+ set_user_prefix: {
+ Args: {
+ p_reviewer: string;
+ p_user: string;
+ p_prefix: string | null;
+ p_icon: string;
+ };
+ Returns: undefined;
+ };
+ claim_bug_sync: {
+ Args: { p_report: string; p_worker: string };
+ Returns: boolean;
+ };
+ receive_bug_reply: {
+ Args: {
+ p_report: string;
+ p_external: string;
+ p_author: string;
+ p_body: string;
+ p_glow: boolean | null;
+ };
+ Returns: boolean;
+ };
+ claim_bug_report: {
+ Args: { p_report: string; p_hash: string; p_owner: string };
+ Returns: undefined;
+ };
create_map_import: {
Args: {
p_owner: string;
diff --git a/shared/leaderboard.ts b/shared/leaderboard.ts
index 574ce04..3422a2d 100644
--- a/shared/leaderboard.ts
+++ b/shared/leaderboard.ts
@@ -5,14 +5,16 @@ export const leaderboardDifficulties = [
"hard",
] as const;
export type LeaderboardDifficulty = (typeof leaderboardDifficulties)[number];
-export type LeaderboardEntry = {
- rank: number;
- player_id: string;
- display_name: string;
- best_score: number;
- average_score: number;
- games_played: number;
-};
+export type LeaderboardEntry =
+ import("./profile-appearance").ProfileAppearance & {
+ rank: number;
+ player_id: string;
+ display_name: string;
+ name_glow?: boolean;
+ best_score: number;
+ average_score: number;
+ games_played: number;
+ };
export type LeaderboardData = {
entries: LeaderboardEntry[];
leaders: LeaderboardEntry[];
diff --git a/shared/locales/ru.json b/shared/locales/ru.json
index 8aa8dc9..272324c 100644
--- a/shared/locales/ru.json
+++ b/shared/locales/ru.json
@@ -714,5 +714,77 @@
"Chat permission requires joining this room": "Чтобы пользоваться чатом, войдите в эту комнату",
"Chat is unavailable in a closed or expired room": "Чат недоступен в закрытой комнате или комнате с истёкшим сроком",
"Too many chat messages. Wait a moment and try again.": "Слишком много сообщений. Подождите немного и попробуйте снова.",
- "Invalid chat message": "Недопустимое сообщение чата"
+ "Invalid chat message": "Недопустимое сообщение чата",
+ "Send bug report": "Сообщить об ошибке",
+ "Report a bug": "Сообщить об ошибке",
+ "Moderator": "Модератор",
+ "Bug finder": "Нашёл ошибку",
+ "Username glow": "Свечение имени",
+ "Choose the color of your glow.": "Выберите цвет свечения вашего имени.",
+ "Support can award a glow for a confirmed bug report.": "Поддержка может подарить свечение за подтверждённую ошибку.",
+ "Glow color": "Цвет свечения",
+ "Save glow color": "Сохранить цвет свечения",
+ "Glow color saved.": "Цвет свечения сохранён.",
+ "Earn a username glow before choosing its color": "Сначала получите свечение имени, чтобы выбрать его цвет",
+ "Choose a valid glow color": "Выберите допустимый цвет свечения",
+ "Player prefixes": "Префиксы игроков",
+ "Find a player and assign a custom prefix with an icon.": "Найдите игрока и выдайте ему свой префикс с иконкой.",
+ "Player name or account ID": "Имя игрока или ID аккаунта",
+ "Find players": "Найти игроков",
+ "No players found.": "Игроки не найдены.",
+ "Custom prefix": "Свой префикс",
+ "Prefix icon": "Иконка префикса",
+ "Save prefix": "Сохранить префикс",
+ "Remove prefix": "Убрать префикс",
+ "Player prefix saved.": "Префикс игрока сохранён.",
+ "Use a prefix of 1–24 characters without hidden controls or markup": "Используйте от 1 до 24 символов без скрытых управляющих символов и разметки",
+ "Star": "Звезда",
+ "Crown": "Корона",
+ "Heart": "Сердце",
+ "Flame": "Огонь",
+ "Gem": "Самоцвет",
+ "Trophy": "Кубок",
+ "Sparkles": "Искры",
+ "Player not found": "Игрок не найден",
+ "PLAYER SUPPORT": "ПОДДЕРЖКА ИГРОКОВ",
+ "Found a bug?": "Нашли ошибку?",
+ "Tell us what happened. We’ll reply here.": "Расскажите, что произошло. Мы ответим здесь.",
+ "Guest reports stay in this browser. Sign in to keep your conversation in your account.": "Обращения гостей сохраняются в этом браузере. Войдите, чтобы сохранить переписку в аккаунте.",
+ "Bug reporting is temporarily unavailable. Please try again later.": "Отправка обращений временно недоступна. Попробуйте позже.",
+ "Your player name": "Ваше имя игрока",
+ "Bug title": "Название ошибки",
+ "What happened?": "Что произошло?",
+ "What did you do, what did you expect, and what happened instead?": "Что вы сделали, чего ожидали и что произошло вместо этого?",
+ "Screenshot (optional)": "Скриншот (необязательно)",
+ "PNG, JPG, or WebP, up to 5 MB. Shared privately with support.": "PNG, JPG или WebP, до 5 МБ. Доступен только вам и поддержке.",
+ "Your bug reports": "Ваши обращения",
+ "Your reports and support replies will appear here.": "Здесь появятся ваши обращения и ответы поддержки.",
+ "Draft": "Черновик",
+ "Waiting for support": "Ожидает поддержки",
+ "← Your bug reports": "← Ваши обращения",
+ "Your report is saved. We’re connecting it to support.": "Обращение сохранено. Передаём его поддержке.",
+ "This report is not sent yet. Return to the form or finish sending it.": "Обращение ещё не отправлено. Вернитесь к форме или завершите отправку.",
+ "Finish sending report": "Завершить отправку",
+ "Keep this report in my account": "Сохранить обращение в аккаунте",
+ "Support awarded you a username glow.": "Поддержка подарила вам свечение имени.",
+ "Account settings": "Настройки аккаунта",
+ "For guest reports, sign in and link this report to receive your reward.": "Если вы отправили обращение как гость, войдите и привяжите его к аккаунту, чтобы получить награду.",
+ "Attached bug screenshot": "Прикреплённый скриншот ошибки",
+ "Support conversation": "Переписка с поддержкой",
+ "Support": "Поддержка",
+ "Delivered to support": "Доставлено поддержке",
+ "Saved · waiting to send": "Сохранено · ожидает отправки",
+ "Message support": "Написать поддержке",
+ "Username glow granted.": "Свечение имени выдано.",
+ "Username glow removed.": "Свечение имени отключено.",
+ "Add a short title, at least 15 characters describing the bug, and your player name.": "Добавьте короткое название, описание ошибки от 15 символов и своё имя игрока.",
+ "Choose a PNG, JPG, or WebP screenshot smaller than 5 MB": "Выберите скриншот PNG, JPG или WebP размером до 5 МБ",
+ "Screenshot upload failed. Please try again.": "Не удалось загрузить скриншот. Попробуйте ещё раз.",
+ "Use 1–6000 characters without hidden controls": "Используйте от 1 до 6000 символов без скрытых управляющих символов",
+ "Open support before sending a report": "Откройте страницу поддержки перед отправкой обращения",
+ "Report not found": "Обращение не найдено",
+ "Submit this report before sending messages": "Отправьте обращение перед отправкой сообщений",
+ "Invalid support message": "Недопустимое сообщение поддержке",
+ "Sign in to link this report": "Войдите, чтобы привязать обращение",
+ "Remove hidden control characters": "Удалите скрытые управляющие символы"
}
diff --git a/shared/multiplayer.ts b/shared/multiplayer.ts
index 3d7f0f3..bfa9af2 100644
--- a/shared/multiplayer.ts
+++ b/shared/multiplayer.ts
@@ -27,6 +27,7 @@ export type RoomChat = {
id: string;
playerId: string;
name: string;
+ appearance?: import("./profile-appearance").ProfileAppearance;
text: string;
createdAt: string;
}[];
@@ -62,6 +63,7 @@ export type RoomView = {
players: {
id: string;
name: string;
+ appearance?: import("./profile-appearance").ProfileAppearance;
left: boolean;
ready: boolean;
submitted: boolean;
diff --git a/shared/profile-appearance.ts b/shared/profile-appearance.ts
new file mode 100644
index 0000000..9fcc25a
--- /dev/null
+++ b/shared/profile-appearance.ts
@@ -0,0 +1,46 @@
+import { z } from "zod";
+export const defaultGlowColor = "#ffe6a1";
+export const glowColorSchema = z
+ .string()
+ .regex(/^#[a-fA-F0-9]{6}$/, "Choose a valid glow color")
+ .transform((color) => color.toLowerCase());
+export const prefixIcons = [
+ "star",
+ "crown",
+ "heart",
+ "flame",
+ "gem",
+ "trophy",
+ "sparkles",
+] as const;
+export type PrefixIcon = (typeof prefixIcons)[number];
+export const customPrefixSchema = z
+ .string()
+ .trim()
+ .min(1)
+ .max(24)
+ .refine(
+ (value) =>
+ !/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069<>]/u.test(value),
+ "Use a prefix of 1–24 characters without hidden controls or markup",
+ );
+export const prefixUpdateSchema = z
+ .object({ prefix: customPrefixSchema.nullable(), icon: z.enum(prefixIcons) })
+ .strict();
+export type ProfileAppearance = {
+ name_glow?: boolean;
+ glow_color?: string;
+ role?: string;
+ bug_finder?: boolean;
+ custom_prefix?: string | null;
+ custom_prefix_icon?: string;
+};
+export const appearanceColumns =
+ "name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon" as const;
+export type BadgeUser = ProfileAppearance & {
+ id: string;
+ display_name: string;
+};
+export function glowColor(value?: string) {
+ return glowColorSchema.safeParse(value).data ?? defaultGlowColor;
+}
diff --git a/supabase/migrations/20261008071054_bug_reports.sql b/supabase/migrations/20261008071054_bug_reports.sql
new file mode 100644
index 0000000..eb55754
--- /dev/null
+++ b/supabase/migrations/20261008071054_bug_reports.sql
@@ -0,0 +1,90 @@
+alter table public.profiles add column name_glow boolean not null default false;
+create function public.protect_name_glow() returns trigger language plpgsql security invoker set search_path='' as $$
+begin
+ if ((tg_op='INSERT' and new.name_glow) or (tg_op='UPDATE' and new.name_glow is distinct from old.name_glow))
+ and current_user not in ('postgres','service_role') then raise exception 'Reward permission required'; end if;
+ return new;
+end; $$;
+create trigger profiles_protect_glow before insert or update on public.profiles for each row execute function public.protect_name_glow();
+revoke all on function public.protect_name_glow() from public,anon,authenticated;
+
+create table public.bug_reports (
+ id uuid primary key default gen_random_uuid(),
+ client_id uuid not null unique,
+ owner_id uuid references public.profiles(id) on delete set null,
+ access_hash text not null check(access_hash ~ '^[a-f0-9]{64}$'),
+ display_name text not null,
+ title text not null check(length(title) between 5 and 120),
+ description text not null check(length(description) between 15 and 6000),
+ page_path text not null default '/',
+ state text not null default 'draft' check(state in ('draft','pending','open')),
+ screenshot_expected boolean not null default false,
+ screenshot_path text,
+ linear_asset_url text,
+ linear_identifier text,
+ issue_synced boolean not null default false,
+ glow_requested boolean not null default false,
+ lease_owner text,
+ lease_until timestamptz,
+ retry_at timestamptz,
+ created_at timestamptz not null default clock_timestamp(),
+ updated_at timestamptz not null default clock_timestamp()
+);
+create index bug_reports_owner on public.bug_reports(owner_id,created_at desc);
+create index bug_reports_guest on public.bug_reports(access_hash,created_at desc) where owner_id is null;
+create table public.bug_report_messages (
+ id uuid primary key default gen_random_uuid(),
+ client_id uuid,
+ report_id uuid not null references public.bug_reports(id) on delete cascade,
+ kind text not null check(kind in ('user','staff','system')),
+ body text not null check(length(body) between 1 and 6000),
+ author_name text not null,
+ delivered boolean not null default false,
+ external_id uuid unique,
+ created_at timestamptz not null default clock_timestamp(),
+ unique(report_id,client_id)
+);
+create index bug_messages_report on public.bug_report_messages(report_id,created_at,id);
+alter table public.bug_reports enable row level security;
+alter table public.bug_report_messages enable row level security;
+revoke all on public.bug_reports,public.bug_report_messages from public,anon,authenticated;
+grant all on public.bug_reports,public.bug_report_messages to service_role;
+
+create function public.claim_bug_sync(p_report uuid,p_worker text) returns boolean language plpgsql security invoker set search_path='' as $$
+begin
+ update public.bug_reports set lease_owner=p_worker,lease_until=clock_timestamp()+interval '2 minutes'
+ where id=p_report and state<>'draft' and (lease_until is null or lease_until'draft' for update;
+ if not found then return false; end if;
+ if exists(select 1 from public.bug_report_messages where external_id=p_external) then return false; end if;
+ if p_glow is null and (p_body is null or length(p_body) not between 1 and 6000) then raise exception 'Invalid support reply'; end if;
+ if p_glow is not null then
+ update public.bug_reports set glow_requested=p_glow,updated_at=clock_timestamp() where id=p_report;
+ if report.owner_id is not null then update public.profiles set name_glow=p_glow where id=report.owner_id; end if;
+ end if;
+ insert into public.bug_report_messages(id,report_id,kind,body,author_name,delivered,external_id)
+ values(gen_random_uuid(),p_report,case when p_glow is null then 'staff' else 'system' end,
+ case when p_glow is null then p_body when p_glow then 'Username glow granted.' else 'Username glow removed.' end,
+ left(coalesce(p_author,'Support'),80),true,p_external);
+ return true;
+end; $$;
+create function public.claim_bug_report(p_report uuid,p_hash text,p_owner uuid) returns void language plpgsql security invoker set search_path='' as $$
+declare report public.bug_reports;
+begin
+ if p_owner is null then raise exception 'Sign in to link this report'; end if;
+ select * into report from public.bug_reports where id=p_report and owner_id is null and access_hash=p_hash for update;
+ if not found then raise exception 'Report not found'; end if;
+ update public.bug_reports set owner_id=p_owner,updated_at=clock_timestamp() where id=p_report;
+ if report.glow_requested then update public.profiles set name_glow=true where id=p_owner; end if;
+end; $$;
+revoke all on function public.claim_bug_sync(uuid,text),public.receive_bug_reply(uuid,uuid,text,text,boolean),public.claim_bug_report(uuid,text,uuid) from public,anon,authenticated;
+grant execute on function public.claim_bug_sync(uuid,text),public.receive_bug_reply(uuid,uuid,text,text,boolean),public.claim_bug_report(uuid,text,uuid) to service_role;
+insert into storage.buckets(id,name,public,file_size_limit)
+ values('minegeo-support','minegeo-support',false,5242880) on conflict(id) do nothing;
diff --git a/supabase/migrations/20261008075322_profile_identity.sql b/supabase/migrations/20261008075322_profile_identity.sql
new file mode 100644
index 0000000..586b7b0
--- /dev/null
+++ b/supabase/migrations/20261008075322_profile_identity.sql
@@ -0,0 +1,67 @@
+alter table public.profiles
+ add column glow_color text not null default '#ffe6a1' check(glow_color ~ '^#[a-fA-F0-9]{6}$'),
+ add column bug_finder boolean not null default false,
+ add column custom_prefix text check(custom_prefix is null or (length(custom_prefix) between 1 and 24 and custom_prefix !~ '[[:cntrl:]]')),
+ add column custom_prefix_icon text not null default 'star' check(custom_prefix_icon in ('star','crown','heart','flame','gem','trophy','sparkles'));
+alter table public.bug_reports add column bug_confirmed boolean not null default false;
+update public.profiles set bug_finder=true where name_glow;
+update public.bug_reports set bug_confirmed=true where glow_requested;
+
+create function public.protect_profile_identity() returns trigger language plpgsql security invoker set search_path='' as $$
+begin
+ if current_user not in ('postgres','service_role') and (
+ (tg_op='INSERT' and (new.role<>'contributor' or new.bug_finder or new.custom_prefix is not null or new.custom_prefix_icon<>'star'))
+ or (tg_op='UPDATE' and (new.role is distinct from old.role or new.bug_finder is distinct from old.bug_finder
+ or new.custom_prefix is distinct from old.custom_prefix or new.custom_prefix_icon is distinct from old.custom_prefix_icon))
+ ) then raise exception 'Moderator permission required'; end if;
+ if tg_op='UPDATE' and new.glow_color is distinct from old.glow_color and not new.name_glow then
+ raise exception 'Earn a username glow before choosing its color';
+ end if;
+ return new;
+end; $$;
+create trigger profiles_protect_identity before insert or update on public.profiles for each row execute function public.protect_profile_identity();
+revoke all on function public.protect_profile_identity() from public,anon,authenticated;
+
+create function public.set_user_prefix(p_reviewer uuid,p_user uuid,p_prefix text,p_icon text) returns void
+language plpgsql security invoker set search_path='' as $$
+begin
+ if not exists(select 1 from public.profiles where id=p_reviewer and role='moderator') then raise exception 'Moderator permission required'; end if;
+ if p_icon not in ('star','crown','heart','flame','gem','trophy','sparkles') or p_icon is null then raise exception 'Invalid prefix icon'; end if;
+ if p_prefix is not null and (length(btrim(p_prefix)) not between 1 and 24 or p_prefix ~ '[[:cntrl:]]') then raise exception 'Invalid custom prefix'; end if;
+ update public.profiles set custom_prefix=case when p_prefix is null then null else btrim(p_prefix) end,custom_prefix_icon=p_icon where id=p_user;
+ if not found then raise exception 'Player not found'; end if;
+end; $$;
+revoke all on function public.set_user_prefix(uuid,uuid,text,text) from public,anon,authenticated;
+grant execute on function public.set_user_prefix(uuid,uuid,text,text) to service_role;
+
+create or replace function public.receive_bug_reply(p_report uuid,p_external uuid,p_author text,p_body text,p_glow boolean default null)
+returns boolean language plpgsql security invoker set search_path='' as $$
+declare report public.bug_reports;
+begin
+ select * into report from public.bug_reports where id=p_report and state<>'draft' for update;
+ if not found then return false; end if;
+ if exists(select 1 from public.bug_report_messages where external_id=p_external) then return false; end if;
+ if p_glow is null and (p_body is null or length(p_body) not between 1 and 6000) then raise exception 'Invalid support reply'; end if;
+ if p_glow is not null then
+ update public.bug_reports set glow_requested=p_glow,bug_confirmed=bug_confirmed or p_glow,updated_at=clock_timestamp() where id=p_report;
+ if report.owner_id is not null then update public.profiles set name_glow=p_glow,bug_finder=bug_finder or p_glow where id=report.owner_id; end if;
+ end if;
+ insert into public.bug_report_messages(id,report_id,kind,body,author_name,delivered,external_id)
+ values(gen_random_uuid(),p_report,case when p_glow is null then 'staff' else 'system' end,
+ case when p_glow is null then p_body when p_glow then 'Username glow granted.' else 'Username glow removed.' end,
+ left(coalesce(p_author,'Support'),80),true,p_external);
+ return true;
+end; $$;
+create or replace function public.claim_bug_report(p_report uuid,p_hash text,p_owner uuid) returns void
+language plpgsql security invoker set search_path='' as $$
+declare report public.bug_reports;
+begin
+ if p_owner is null then raise exception 'Sign in to link this report'; end if;
+ select * into report from public.bug_reports where id=p_report and owner_id is null and access_hash=p_hash for update;
+ if not found then raise exception 'Report not found'; end if;
+ update public.bug_reports set owner_id=p_owner,updated_at=clock_timestamp() where id=p_report;
+ if report.bug_confirmed then update public.profiles set bug_finder=true,name_glow=name_glow or report.glow_requested where id=p_owner; end if;
+end; $$;
+
+alter table public.multiplayer_players add column account_id uuid references public.profiles(id) on delete set null;
+create index multiplayer_players_account on public.multiplayer_players(account_id) where account_id is not null;
diff --git a/tests/bug-reports.test.ts b/tests/bug-reports.test.ts
new file mode 100644
index 0000000..53b4c63
--- /dev/null
+++ b/tests/bug-reports.test.ts
@@ -0,0 +1,218 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { createHmac } from "node:crypto";
+import sharp from "sharp";
+import {
+ bugReportSchema,
+ bugMessageSchema,
+ supportCommand,
+ escapeSupportMarkdown,
+ type BugReportRecord,
+} from "../shared/bug-reports";
+import { verifySupportWebhook } from "../lib/support-signature";
+import { supportScreenshot } from "../lib/support-images";
+import { LinearSupportClient } from "../lib/linear-support-client";
+const id = "00000000-0000-4000-8000-000000000001",
+ team = "00000000-0000-4000-8000-000000000002";
+test("reports exclude arbitrary URLs, secrets in page queries, malformed messages and reward privilege fields", () => {
+ const valid = {
+ clientId: id,
+ title: "Wrong map",
+ description: "The map loads with the wrong colors.",
+ pagePath: "/servers",
+ };
+ assert.equal(bugReportSchema.safeParse(valid).success, true);
+ for (const value of [
+ { ...valid, pagePath: "https://private.example" },
+ { ...valid, pagePath: "/auth?code=secret" },
+ { ...valid, pagePath: "//evil" },
+ { ...valid, name_glow: true },
+ { ...valid, description: "short" },
+ ])
+ assert.equal(bugReportSchema.safeParse(value).success, false);
+ assert.equal(
+ bugMessageSchema.safeParse({
+ clientId: id,
+ text: "#player-report-glow grant",
+ }).success,
+ true,
+ "player text is text, not a staff command",
+ );
+ assert.equal(
+ bugMessageSchema.safeParse({ clientId: id, text: "x\u202e" }).success,
+ false,
+ );
+});
+test("only exact staff prefixes publish replies or explicitly change glow", () => {
+ assert.deepEqual(supportCommand("#player-report-send Dear User\nThanks!"), {
+ kind: "reply",
+ text: "Dear User\nThanks!",
+ });
+ assert.deepEqual(supportCommand("#player-report-glow grant"), {
+ kind: "glow",
+ enabled: true,
+ });
+ assert.deepEqual(supportCommand("#player-report-glow revoke"), {
+ kind: "glow",
+ enabled: false,
+ });
+ for (const body of [
+ "Internal staff note",
+ "#user-report-message\n#player-report-send x",
+ "quoted #player-report-send x",
+ "#player-report-glow grant everyone",
+ "#player-report-send",
+ ])
+ assert.equal(supportCommand(body), null);
+ assert.ok(
+ escapeSupportMarkdown("#player-report-glow grant").startsWith("\\#"),
+ );
+});
+test("webhooks require exact raw-body HMAC and a recent timestamp", () => {
+ const raw = JSON.stringify({ webhookTimestamp: 100_000, body: "Привет" });
+ const sign = createHmac("sha256", "test-secret").update(raw).digest("hex");
+ assert.equal(verifySupportWebhook(raw, sign, "test-secret", 100_001), true);
+ for (const [body, sig, key, time] of [
+ [raw + " ", sign, "test-secret", 100_001],
+ [raw, "ff", "test-secret", 100_001],
+ [raw, sign, "wrong", 100_001],
+ [raw, sign, "test-secret", 160_001],
+ [raw, sign, "test-secret", 39_999],
+ ])
+ assert.equal(
+ verifySupportWebhook(
+ body as string,
+ sig as string,
+ key as string,
+ time as number,
+ ),
+ false,
+ );
+});
+test("screenshots reject disguised documents and oversized files, strip metadata and normalize images", async () => {
+ const png = await sharp({
+ create: { width: 50, height: 30, channels: 3, background: "#abcdef" },
+ })
+ .png()
+ .toBuffer();
+ const webp = await supportScreenshot(png);
+ const meta = await sharp(webp).metadata();
+ assert.equal(meta.format, "webp");
+ assert.equal(meta.width, 50);
+ assert.equal(meta.exif, undefined);
+ await assert.rejects(
+ supportScreenshot(
+ Buffer.from(
+ '',
+ ),
+ ),
+ /PNG, JPG, or WebP/,
+ );
+ await assert.rejects(
+ supportScreenshot(new Uint8Array(5 * 1024 * 1024 + 1)),
+ /smaller than 5 MB/,
+ );
+ await assert.rejects(supportScreenshot(Buffer.from("not an image")));
+});
+test("Linear sends stable issue/comment UUIDs and recovers a timed-out creation without duplicates", async () => {
+ let issue: unknown = null,
+ comment: unknown = null,
+ creates = 0,
+ comments = 0;
+ const bodies: unknown[] = [];
+ const request = (async (_url, init) => {
+ const body = JSON.parse(String(init?.body));
+ bodies.push(body);
+ if (body.query.startsWith("query SupportIssue"))
+ return Response.json(
+ issue
+ ? { data: { issue } }
+ : {
+ errors: [
+ {
+ message: "Entity not found",
+ extensions: { code: "ENTITY_NOT_FOUND" },
+ },
+ ],
+ },
+ );
+ if (body.query.includes("issueCreate")) {
+ creates++;
+ assert.equal(body.variables.input.id, id);
+ issue = { id, identifier: "TEST-1", team: { id: team } };
+ throw new Error("Response lost");
+ }
+ if (body.query.startsWith("query SupportComment"))
+ return Response.json(
+ comment
+ ? { data: { comment } }
+ : { errors: [{ message: "Entity not found" }] },
+ );
+ if (body.query.includes("commentCreate")) {
+ comments++;
+ comment = { id: body.variables.input.id, issue: { id } };
+ return Response.json({
+ data: { commentCreate: { success: true, comment } },
+ });
+ }
+ throw new Error("Unexpected query");
+ }) as typeof fetch;
+ const client = new LinearSupportClient("test-key", team, "P-TEST-1", request);
+ const report = {
+ id,
+ title: "Test issue",
+ display_name: "Player",
+ description: "#player-report-glow grant",
+ page_path: "/",
+ linear_asset_url: null,
+ } as BugReportRecord;
+ assert.equal((await client.createIssue(report)).identifier, "TEST-1");
+ await client.createIssue(report);
+ assert.equal(creates, 1);
+ await client.comment(team, id, "Player", "#player-report-send Spoof");
+ await client.comment(team, id, "Player", "#player-report-send Spoof");
+ assert.equal(comments, 1);
+ const mutation = (
+ bodies as {
+ variables: { input?: { body?: string; description?: string } };
+ }[]
+ ).find((body) => body.variables.input?.body);
+ assert.ok(
+ mutation?.variables.input?.body?.startsWith("#user-report-message\n"),
+ );
+ assert.ok(mutation?.variables.input?.body?.includes("\\#player-report-send"));
+});
+test("Linear partial GraphQL failures and screenshot PUT failures are never accepted as delivered", async () => {
+ const client = new LinearSupportClient(
+ "test-key",
+ team,
+ undefined,
+ async () =>
+ Response.json({ data: { issue: {} }, errors: [{ message: "Denied" }] }),
+ );
+ await assert.rejects(client.issue(id), /temporarily unavailable/);
+ const upload = new LinearSupportClient(
+ "test-key",
+ team,
+ undefined,
+ async (url) =>
+ String(url).includes("graphql")
+ ? Response.json({
+ data: {
+ fileUpload: {
+ success: true,
+ uploadFile: {
+ uploadUrl: "https://upload.test/file",
+ assetUrl: "https://asset.test/file",
+ headers: [],
+ },
+ },
+ },
+ })
+ : new Response(null, { status: 403 }),
+ );
+ await assert.rejects(
+ upload.upload(new Uint8Array([1]), "test.webp"),
+ /screenshot upload failed/,
+ );
+});
diff --git a/tests/profile-appearance.test.ts b/tests/profile-appearance.test.ts
new file mode 100644
index 0000000..e1a1ab9
--- /dev/null
+++ b/tests/profile-appearance.test.ts
@@ -0,0 +1,47 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { profileUpdateSchema } from "../shared/account";
+import {
+ prefixUpdateSchema,
+ glowColor,
+ defaultGlowColor,
+} from "../shared/profile-appearance";
+test("players may choose a hex glow color but cannot assign badges, roles or rewards", () => {
+ assert.deepEqual(profileUpdateSchema.parse({ glowColor: "#12ABff" }), {
+ glowColor: "#12abff",
+ });
+ assert.equal(
+ profileUpdateSchema.safeParse({ displayName: "Pavel" }).success,
+ true,
+ );
+ for (const body of [
+ {},
+ { role: "moderator" },
+ { bug_finder: true },
+ { name_glow: true },
+ { custom_prefix: "Admin" },
+ { glowColor: "red; background:url(https://example.com)" },
+ { glowColor: "#fff" },
+ ])
+ assert.equal(profileUpdateSchema.safeParse(body).success, false);
+ assert.equal(glowColor("url(example.com)"), defaultGlowColor);
+});
+test("custom prefixes are bounded plain text with a known icon and support explicit removal", () => {
+ assert.deepEqual(
+ prefixUpdateSchema.parse({ prefix: " Map artist ", icon: "gem" }),
+ { prefix: "Map artist", icon: "gem" },
+ );
+ assert.equal(
+ prefixUpdateSchema.safeParse({ prefix: null, icon: "star" }).success,
+ true,
+ );
+ for (const body of [
+ { prefix: "", icon: "star" },
+ { prefix: "x".repeat(25), icon: "star" },
+ { prefix: "