Add player bug reports and profile identity features

This commit is contained in:
2026-10-08 18:27:20 +05:00
parent 36e8d11eb4
commit 0b08672f4a
51 changed files with 3938 additions and 45 deletions

View File

@@ -20,3 +20,10 @@ MINEGEO_RENDER_IDLE_SECONDS=600
# Allow Chromium to use the GPU; software forces SwiftShader for headless hosts.
MINEGEO_RENDER_BACKEND=auto
MINEGEO_MAX_UPLOAD_BYTES=268435456
# Player bug reports. Keep secrets server-only; configure Production on Vercel.
LINEAR_API_KEY=
LINEAR_WEBHOOK_SECRET=
LINEAR_TEAM_ID=
# Optional UUID or project identifier (MineGeo: P-DUN-3).
LINEAR_PROJECT_ID=

View File

@@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { admin, required } from "@/lib/supabase";
import { verifySupportWebhook } from "@/lib/support-signature";
import { supportCommand } from "@/shared/bug-reports";
export const runtime = "nodejs";
export async function POST(request: NextRequest) {
const secret = process.env.LINEAR_WEBHOOK_SECRET;
if (!secret)
return NextResponse.json(
{ error: "Support integration is not configured" },
{ status: 503 },
);
const reader = request.body?.getReader();
if (!reader) return new NextResponse(null, { status: 400 });
const chunks: Uint8Array[] = [];
let size = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
size += value.length;
if (size > 256 * 1024) {
await reader.cancel();
return new NextResponse(null, { status: 413 });
}
chunks.push(value);
}
const raw = Buffer.concat(chunks).toString("utf8");
if (
!verifySupportWebhook(raw, request.headers.get("linear-signature"), secret)
)
return new NextResponse(null, { status: 401 });
let decoded: unknown;
try {
decoded = JSON.parse(raw);
} catch {
return new NextResponse(null, { status: 400 });
}
const payload = z
.object({
action: z.string(),
type: z.string(),
actor: z
.object({
id: z.string(),
type: z.string(),
name: z.string().optional(),
})
.nullable()
.optional(),
data: z.object({
id: z.string().uuid(),
issueId: z.string().uuid().optional(),
body: z.string().optional(),
userId: z.string().optional(),
}),
})
.safeParse(decoded);
if (!payload.success) return new NextResponse(null, { status: 400 });
const event = payload.data;
// Only explicit new staff comments are public. Internal comments and user echoes stay private.
if (
event.action !== "create" ||
event.type !== "Comment" ||
event.actor?.type.toLowerCase() !== "user" ||
event.data.userId !== event.actor.id ||
!event.data.issueId
)
return NextResponse.json({ ok: true });
const command = supportCommand(event.data.body ?? "");
if (!command) return NextResponse.json({ ok: true });
try {
required(
await admin().rpc("receive_bug_reply", {
p_report: event.data.issueId,
p_external: event.data.id,
p_author: event.actor.name ?? "Support",
p_body: command.kind === "reply" ? command.text : "",
p_glow: command.kind === "glow" ? command.enabled : null,
}),
);
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json(
{ error: "Support delivery failed" },
{ status: 503 },
);
}
}

View File

@@ -5,6 +5,7 @@ import { Dashboard } from "@/components/Dashboard";
import { admin, user, required } from "@/lib/supabase";
import Link from "next/link";
import { accountProfile } from "@/lib/profile";
import { Username } from "@/components/Username";
export const dynamic = "force-dynamic";
export default async function DashboardPage() {
const { t, formatDate } = await getI18n();
@@ -33,7 +34,12 @@ export default async function DashboardPage() {
<span className="eyebrow">{t("THE CARTOGRAPHER’S DESK")}</span>
<h1>{t("My expedition.")}</h1>
<p className="lead">
{t("Welcome back,")} {profile?.display_name}.
{t("Welcome back,")}{" "}
<Username
name={profile?.display_name ?? "Explorer"}
appearance={profile ?? undefined}
/>
.
</p>
<Dashboard />
{history.length > 0 && (

View File

@@ -15,6 +15,7 @@ import { getI18n } from "@/lib/i18n";
import "./polish.css";
import "./mobile-game.css";
import "./multiplayer.css";
import "./support.css";
import { SiteSettingsProvider } from "@/components/SiteSettingsProvider";
import { initialSiteSettings } from "@/lib/site-settings";
import { user } from "@/lib/supabase";

View File

@@ -286,7 +286,7 @@
font-size: 14px;
}
.room-chat-log strong,
.room-chat-log span {
.room-chat-log p > span {
display: block;
}
.room-chat-log strong {

View File

@@ -1,4 +1,5 @@
import { getI18n } from "@/lib/i18n";
import { Username } from "@/components/Username";
import { notFound } from "next/navigation";
import Image from "next/image";
import { Header, Footer } from "@/components/Shell";
@@ -31,7 +32,8 @@ export default async function ServerPage({
<h1>{map.name}</h1>
<p className="lead">{map.description}</p>
<p className="map-credit">
{t("Created by")} {map.credit}
{t("Created by")}{" "}
<Username name={map.credit} appearance={map.creditAppearance} />
</p>
<ReportContent datasetId={id} />
{account?.id === map.owner_id && <ExportMap id={id} />}

View File

@@ -5,6 +5,7 @@ import { Header, Footer } from "@/components/Shell";
import { AccountSettings } from "@/components/AccountSettings";
import { user } from "@/lib/supabase";
import { accountProfile } from "@/lib/profile";
import { Username } from "@/components/Username";
export async function generateMetadata() {
const { t } = await getI18n();
return { title: t("Account settings") };
@@ -32,12 +33,14 @@ export default async function Settings({
<span className="eyebrow">{t("YOUR CARTOGRAPHER ACCOUNT")}</span>
<h1>{t("Account settings.")}</h1>
<p className="lead">
<Username name={profile.display_name} appearance={profile} /> ·{" "}
{t("Your name, your sign-in, your next expedition.")}
</p>
</div>
<AccountSettings
email={account.email ?? ""}
displayName={profile.display_name}
appearance={profile}
passwordUpdated={query.password === "updated"}
/>
</main>

264
app/support.css Normal file
View File

@@ -0,0 +1,264 @@
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.header-report {
display: inline-flex;
gap: 7px;
align-items: center;
justify-content: center;
min-height: 44px;
color: var(--text);
font-size: 12px;
white-space: nowrap;
}
.support-layout {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(220px, 1fr);
gap: 50px;
}
.support-layout h1,
.support-conversation h1 {
margin-top: 14px;
overflow-wrap: anywhere;
}
.support-form form,
.support-conversation form {
display: grid;
gap: 12px;
}
.support-form label,
.support-conversation label {
font-weight: 650;
display: flex;
align-items: center;
gap: 8px;
}
.support-form input,
.support-form textarea,
.support-conversation textarea {
width: 100%;
min-width: 0;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--panel);
color: var(--text);
padding: 12px;
font: 16px var(--font-body, sans-serif);
}
.support-form textarea,
.support-conversation textarea {
resize: vertical;
}
.support-form small {
color: var(--muted);
}
.support-form form .button,
.support-conversation form .button {
justify-self: start;
}
.support-inbox {
border-left: 1px solid var(--line);
padding-left: 28px;
}
.support-inbox ul {
list-style: none;
margin: 0;
padding: 0;
}
.support-inbox li {
padding: 16px 0;
border-bottom: 1px solid var(--line);
overflow-wrap: anywhere;
}
.support-inbox strong,
.support-inbox small {
display: block;
}
.support-inbox small {
margin-top: 6px;
color: var(--muted);
}
.support-conversation {
max-width: 850px;
margin: 0 auto;
}
.support-conversation > .eyebrow {
display: block;
margin-top: 28px;
}
.support-screenshot {
display: block;
max-width: 100%;
max-height: 400px;
object-fit: contain;
border: 1px solid var(--line);
border-radius: 12px;
margin: 24px 0;
}
.support-messages {
display: grid;
gap: 16px;
margin: 24px 0;
}
.support-message {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
padding: 18px;
overflow-wrap: anywhere;
}
.support-message-staff {
border-color: var(--accent, #8dce66);
}
.support-message-system {
border-style: dashed;
}
.support-message header {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
.support-message small {
color: var(--muted);
}
.support-message p {
white-space: pre-wrap;
margin: 12px 0;
}
.support-reward {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
padding: 16px;
border: 1px solid #dac880;
border-radius: 12px;
}
.support-reward small {
flex-basis: 100%;
}
.username-glow {
color: #ffe6a1;
text-shadow:
0 0 8px #ffca6090,
0 0 18px #ffca6060;
}
.username {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 5px;
max-width: 100%;
vertical-align: middle;
overflow-wrap: anywhere;
white-space: normal;
}
.username > span {
min-width: 0;
max-width: 100%;
}
.username-prefix {
display: inline-flex;
align-items: center;
gap: 4px;
font:
600 11px/1.4 Arial,
sans-serif;
padding: 2px 6px;
border: 1px solid currentColor;
border-radius: 5px;
text-shadow: none;
}
.username-prefix svg {
flex-shrink: 0;
}
.username-moderator {
color: #91d3ff;
}
.username-bug-finder {
color: #ffe6a1;
}
.username-custom-prefix {
color: #cdb8ff;
}
.username-preview {
padding: 18px;
border: 1px solid var(--line);
border-radius: 10px;
margin: 16px 0;
}
.glow-settings input[type="color"] {
width: 90px;
height: 48px;
cursor: pointer;
}
.prefix-search {
display: flex;
align-items: end;
flex-wrap: wrap;
gap: 12px;
}
.prefix-search .field {
flex: 1;
min-width: 200px;
}
.prefix-player-list {
list-style: none;
padding: 0;
display: grid;
gap: 8px;
max-height: 300px;
overflow-y: auto;
}
.prefix-player-list button {
width: 100%;
text-align: left;
background: var(--panel);
color: var(--text);
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
}
.prefix-player-list button[aria-pressed="true"] {
border-color: #91d3ff;
}
.prefix-player-list small {
display: block;
color: var(--muted);
margin-top: 5px;
overflow-wrap: anywhere;
}
.prefix-editor {
display: grid;
gap: 12px;
margin-top: 20px;
}
@media (max-width: 1000px) {
.header-report span {
display: none;
}
.header-report {
width: 44px;
}
.header-actions {
gap: 6px;
}
}
@media (max-width: 700px) {
.support-layout {
grid-template-columns: 1fr;
gap: 32px;
}
.support-inbox {
border-left: 0;
padding-left: 0;
border-top: 1px solid var(--line);
padding-top: 20px;
}
.header-actions {
margin-left: auto;
}
}

22
app/support/[id]/page.tsx Normal file
View File

@@ -0,0 +1,22 @@
import { notFound } from "next/navigation";
import { z } from "zod";
import { Header, Footer } from "@/components/Shell";
import { SupportConversation } from "@/components/Support";
export const metadata = { robots: { index: false, follow: false } };
export default async function Report({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
if (!z.string().uuid().safeParse(id).success) notFound();
return (
<>
<Header />
<main className="section page-content">
<SupportConversation key={id} id={id} />
</main>
<Footer />
</>
);
}

27
app/support/page.tsx Normal file
View File

@@ -0,0 +1,27 @@
import { Header, Footer } from "@/components/Shell";
import { SupportInbox } from "@/components/Support";
import { getI18n } from "@/lib/i18n";
export async function generateMetadata() {
const { t } = await getI18n();
return { title: t("Report a bug") };
}
export default async function Support({
searchParams,
}: {
searchParams: Promise<{ from?: string }>;
}) {
const query = await searchParams;
const pagePath =
query.from && /^\/(?!\/)[a-zA-Z0-9/_-]{0,299}$/.test(query.from)
? query.from
: "/";
return (
<>
<Header />
<main className="section page-content">
<SupportInbox pagePath={pagePath} />
</main>
<Footer />
</>
);
}

View File

@@ -7,13 +7,20 @@ import { UserRound, LockKeyhole, Mail } from "lucide-react";
import { api } from "./StartGame";
import { PasswordForm } from "./PasswordForm";
import { displayNameSchema } from "@/shared/account";
import {
defaultGlowColor,
type ProfileAppearance,
} from "@/shared/profile-appearance";
import { Username } from "./Username";
export function AccountSettings({
email,
displayName,
appearance,
passwordUpdated = false,
}: {
email: string;
displayName: string;
appearance: ProfileAppearance;
passwordUpdated?: boolean;
}) {
const { t } = useI18n();
@@ -25,6 +32,25 @@ export function AccountSettings({
[recoveryError, setRecoveryError] = useState(""),
[sending, setSending] = useState(false);
const router = useRouter();
const [color, setColor] = useState(appearance.glow_color ?? defaultGlowColor);
const [colorBusy, setColorBusy] = useState(false),
[colorError, setColorError] = useState(""),
[colorSaved, setColorSaved] = useState(false);
async function saveColor(event: React.FormEvent) {
event.preventDefault();
setColorBusy(true);
setColorError("");
setColorSaved(false);
try {
await api("profile", { glowColor: color }, "PATCH");
setColorSaved(true);
router.refresh();
} catch (error) {
setColorError((error as Error).message);
} finally {
setColorBusy(false);
}
}
async function save(event: React.FormEvent) {
event.preventDefault();
setError("");
@@ -119,6 +145,49 @@ export function AccountSettings({
</button>
</form>
</section>
<section className="settings-card glow-settings">
<h2>{t("Username glow")}</h2>
<p className="muted">
{appearance.name_glow
? t("Choose the color of your glow.")
: t("Support can award a glow for a confirmed bug report.")}
</p>
<div className="username-preview">
<Username
name={name}
appearance={{ ...appearance, glow_color: color }}
/>
</div>
{appearance.name_glow && (
<form onSubmit={saveColor}>
<label className="field">
{t("Glow color")}
<input
type="color"
value={color}
onChange={(event) => {
setColor(event.target.value);
setColorSaved(false);
}}
disabled={colorBusy}
/>
</label>
{colorError && (
<p role="alert" className="error-message">
{t(colorError)}
</p>
)}
{colorSaved && (
<p role="status" className="success-message">
{t("Glow color saved.")}
</p>
)}
<button className="button primary" disabled={colorBusy}>
{colorBusy ? t("Saving…") : t("Save glow color")}
</button>
</form>
)}
</section>
<section className="settings-card">
<div className="settings-heading">
<LockKeyhole size={22} />

View File

@@ -3,6 +3,7 @@ import { useI18n } from "@/components/I18nProvider";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { PixelTrophy, PlayerAvatar } from "./PixelArt";
import { Username } from "./Username";
import {
type LeaderboardData,
type LeaderboardEntry,
@@ -105,7 +106,7 @@ export function LeaderboardTable({
<div className="leaderboard-player">
<PlayerAvatar id={entry.player_id} />
<span className="player-name">
{entry.display_name}
<Username name={entry.display_name} appearance={entry} />
{entry.player_id === playerId && (
<span className="you-label">{t("You")}</span>
)}
@@ -152,7 +153,9 @@ export function WinnersPodium({ leaders }: { leaders: LeaderboardEntry[] }) {
})}
</span>
<PlayerAvatar id={entry.player_id} />
<h2>{entry.display_name}</h2>
<h2>
<Username name={entry.display_name} appearance={entry} />
</h2>
<p>
{formatNumber(entry.best_score)} <span>/ 25,000</span>
</p>

View File

@@ -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({
<p>{map.description}</p>
{map.credit && (
<p className="map-credit">
{t("By")} {map.credit}
{t("By")}{" "}
<Username
name={map.credit}
appearance={map.creditAppearance}
glow={map.creditGlow}
/>
</p>
)}
<div className="card-meta">

View File

@@ -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 && <ModeratorSettings />}
{authorized && <ModeratorPrefixes />}
{error && (
<p role="alert" className="error-message">
{t(error)}

View File

@@ -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<BadgeUser[]>([]),
[selected, setSelected] = useState<BadgeUser | null>(null);
const [prefix, setPrefix] = useState(""),
[icon, setIcon] = useState<PrefixIcon>("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 (
<section
className="tool-card moderator-prefixes"
aria-labelledby="prefix-heading"
>
<div className="settings-heading">
<Tags size={24} aria-hidden="true" />
<div>
<h2 id="prefix-heading">{t("Player prefixes")}</h2>
<p>{t("Find a player and assign a custom prefix with an icon.")}</p>
</div>
</div>
<form onSubmit={find} className="prefix-search">
<label className="field">
{t("Player name or account ID")}
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
maxLength={64}
disabled={busy}
/>
</label>
<button className="button" disabled={busy}>
{t("Find players")}
</button>
</form>
{searched && users.length === 0 && (
<p className="muted">{t("No players found.")}</p>
)}
{users.length > 0 && (
<ul className="prefix-player-list">
{users.map((user) => (
<li key={user.id}>
<button
type="button"
disabled={busy}
aria-pressed={selected?.id === user.id}
onClick={() => {
setSelected(user);
setPrefix(user.custom_prefix ?? "");
setIcon(
prefixIcons.includes(user.custom_prefix_icon as PrefixIcon)
? (user.custom_prefix_icon as PrefixIcon)
: "star",
);
setSaved(false);
setError("");
}}
>
<Username name={user.display_name} appearance={user} />
<small>{user.id}</small>
</button>
</li>
))}
</ul>
)}
{selected && (
<form
onSubmit={(event) => {
event.preventDefault();
void save();
}}
className="prefix-editor"
>
<label className="field">
{t("Custom prefix")}
<input
value={prefix}
onChange={(event) => {
setPrefix(event.target.value);
setSaved(false);
}}
minLength={1}
maxLength={24}
required
disabled={busy}
/>
</label>
<div className="field">
<label htmlFor="custom-prefix-icon">{t("Prefix icon")}</label>
<select
id="custom-prefix-icon"
value={icon}
onChange={(event) => {
setIcon(event.target.value as PrefixIcon);
setSaved(false);
}}
disabled={busy}
>
{prefixIcons.map((value) => (
<option key={value} value={value}>
{iconLabels[value]}
</option>
))}
</select>
</div>
<div className="username-preview">
<Username
name={selected.display_name}
appearance={{
...selected,
custom_prefix: prefix,
custom_prefix_icon: icon,
}}
/>
</div>
<div className="inline-actions">
<button className="button primary" disabled={busy}>
{t("Save prefix")}
</button>
<button
type="button"
className="button"
disabled={busy || !selected.custom_prefix}
onClick={() => void save(true)}
>
{t("Remove prefix")}
</button>
</div>
</form>
)}
{error && (
<p role="alert" className="error-message">
{t(error)}
</p>
)}
{saved && (
<p role="status" className="success-message">
{t("Player prefix saved.")}
</p>
)}
</section>
);
}

View File

@@ -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) => (
<li key={player.id}>
<span>
{player.name}
<Username name={player.name} appearance={player.appearance} />
{player.id === room.you && <small> {t("(you)")}</small>}
<small>
{player.left

View File

@@ -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 && (
<Crown size={17} aria-label={t("Room owner")} />
)}{" "}
{player.name}{" "}
<Username
name={player.name}
appearance={player.appearance}
/>{" "}
{player.id === room.you && <small>{t("(you)")}</small>}
</span>
{owner && player.id !== room.you && (
@@ -304,7 +308,16 @@ export function MultiplayerRoom({ id }: { id: string }) {
<>
<p className="lead">
<Trophy size={24} aria-hidden="true" />{" "}
{winners.map((player) => player.name).join(" & ")} ·{" "}
{winners.map((player, index) => (
<span key={player.id}>
{index > 0 ? " & " : ""}
<Username
name={player.name}
appearance={player.appearance}
/>
</span>
))}{" "}
·{" "}
{t("{count} points", {
count: formatNumber(winners[0]?.total ?? 0),
})}
@@ -334,7 +347,10 @@ export function MultiplayerRoom({ id }: { id: string }) {
>
<td>{player.rank}</td>
<td>
{player.name}
<Username
name={player.name}
appearance={player.appearance}
/>
{player.left && <small> · {t("Left room")}</small>}
</td>
{room.rounds.map((turn) => (

View File

@@ -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 (
<Link
className="header-report"
href={`/support?from=${encodeURIComponent(pathname)}`}
aria-label={t("Report a bug")}
>
<Bug size={18} aria-hidden="true" />
<span>{t("Report a bug")}</span>
</Link>
);
}

View File

@@ -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" : ""}
>
<strong>
{message.name}
<Username name={message.name} appearance={message.appearance} />
{message.playerId === playerId && ` ${t("(you)")}`}
</strong>
<span>{message.text}</span>

View File

@@ -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() {
<div className="header-inner">
<Brand />
<SiteNav />
<Link
href={account ? "/dashboard" : "/log-in"}
className="header-account"
>
{account ? t("My account") : t("Log in")} <ArrowUpRight size={15} />
</Link>
<div className="header-actions">
<ReportBugButton />
<Link
href={account ? "/dashboard" : "/log-in"}
className="header-account"
>
{account ? t("My account") : t("Log in")} <ArrowUpRight size={15} />
</Link>
</div>
</div>
</header>
);

465
components/Support.tsx Normal file
View File

@@ -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<Inbox | null>(null),
[error, setError] = useState(""),
[busy, setBusy] = useState(false);
const [title, setTitle] = useState(""),
[description, setDescription] = useState(""),
[name, setName] = useState(""),
[file, setFile] = useState<File | null>(null);
const nonce = useRef<string | null>(null);
useEffect(() => {
let stopped = false;
api<Inbox>("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 (
<div className="support-layout">
<section className="support-form">
<span className="eyebrow">
<Bug size={16} />
{t("PLAYER SUPPORT")}
</span>
<h1>{t("Found a bug?")}</h1>
<p className="lead">{t("Tell us what happened. We’ll reply here.")}</p>
{!inbox?.signedIn && (
<p className="muted">
{t(
"Guest reports stay in this browser. Sign in to keep your conversation in your account.",
)}{" "}
<Link href="/log-in?next=%2Fsupport">{t("Log in")}</Link>
</p>
)}
{error && (
<p className="error-message" role="alert">
{t(error)}
</p>
)}
{inbox && !inbox.ready && (
<p role="status">
{t(
"Bug reporting is temporarily unavailable. Please try again later.",
)}
</p>
)}
<form
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
>
{!inbox?.signedIn && (
<>
<label htmlFor="report-name">{t("Your player name")}</label>
<input
id="report-name"
value={name}
onChange={(event) => {
nonce.current = null;
setName(event.target.value);
}}
required
minLength={2}
maxLength={32}
autoComplete="nickname"
disabled={busy}
/>
</>
)}
<label htmlFor="bug-title">{t("Bug title")}</label>
<input
id="bug-title"
value={title}
onChange={(event) => {
nonce.current = null;
setTitle(event.target.value);
}}
minLength={5}
maxLength={120}
required
disabled={busy}
/>
<label htmlFor="bug-description">{t("What happened?")}</label>
<textarea
id="bug-description"
value={description}
onChange={(event) => {
nonce.current = null;
setDescription(event.target.value);
}}
minLength={15}
maxLength={6000}
required
rows={7}
placeholder={t(
"What did you do, what did you expect, and what happened instead?",
)}
disabled={busy}
/>
<label htmlFor="bug-screenshot">
<ImagePlus size={16} /> {t("Screenshot (optional)")}
</label>
<input
id="bug-screenshot"
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={busy}
onChange={(event) => {
nonce.current = null;
setFile(event.target.files?.[0] ?? null);
}}
/>
<small>
{t("PNG, JPG, or WebP, up to 5 MB. Shared privately with support.")}
</small>
<button className="button primary" disabled={busy || !inbox?.ready}>
<Send size={17} />
{busy ? t("Sending…") : t("Send bug report")}
</button>
</form>
</section>
<section className="support-inbox">
<h2>{t("Your bug reports")}</h2>
{!inbox ? (
<p className="muted">{t("Loading…")}</p>
) : inbox.reports.length === 0 ? (
<p className="muted">
{t("Your reports and support replies will appear here.")}
</p>
) : (
<ul>
{inbox.reports.map((report) => (
<li key={report.id}>
<Link href={`/support/${report.id}`}>
<strong>{report.title}</strong>
<small>
{formatDate(report.created_at)} ·{" "}
{report.linear_identifier ??
t(
report.state === "draft"
? "Draft"
: "Waiting for support",
)}
</small>
</Link>
</li>
))}
</ul>
)}
</section>
</div>
);
}
export function SupportConversation({ id }: { id: string }) {
const { t, formatDate } = useI18n();
const [report, setReport] = useState<BugReportView | null>(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<BugReportView>(`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<typeof setTimeout>;
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<BugReportView>(`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 (
<div className="support-conversation">
<Link className="text-link" href="/support">
{t("← Your bug reports")}
</Link>
{error && (
<p className="error-message" role="alert">
{t(error)}{" "}
<button className="text-link" onClick={() => void load()}>
{t("Retry")}
</button>
</p>
)}
{!report ? (
<p>{t("Loading…")}</p>
) : (
<>
<span className="eyebrow">
{t("PLAYER SUPPORT")} ·{" "}
{report.linear_identifier ?? t("Waiting for support")}
</span>
<h1>{report.title}</h1>
{report.state === "pending" && (
<p role="status">
{t("Your report is saved. We’re connecting it to support.")}
</p>
)}
{report.state === "draft" && (
<>
<p>
{t(
"This report is not sent yet. Return to the form or finish sending it.",
)}
</p>
<button
className="button primary"
disabled={busy}
onClick={() => void action("submit", {})}
>
{t("Finish sending report")}
</button>
</>
)}
{report.canClaim && (
<button
className="button"
disabled={busy}
onClick={() => void action("claim", {})}
>
{t("Keep this report in my account")}
</button>
)}
{report.glow_requested && (
<p className="support-reward">
<Sparkles size={18} />
{t("Support awarded you a username glow.")}{" "}
{!report.canClaim && (
<Link
href={
report.isGuest
? `/log-in?next=${encodeURIComponent(`/support/${id}`)}`
: "/settings"
}
>
{report.isGuest ? t("Log in") : t("Account settings")}
</Link>
)}
{report.isGuest && !report.canClaim && (
<small>
{t(
"For guest reports, sign in and link this report to receive your reward.",
)}
</small>
)}
</p>
)}
{report.screenshot && (
<a href={report.screenshot} target="_blank" rel="noreferrer">
<img
className="support-screenshot"
src={report.screenshot}
alt={t("Attached bug screenshot")}
/>
</a>
)}
<div
className="support-messages"
role="log"
aria-live="polite"
aria-relevant="additions"
aria-label={t("Support conversation")}
>
{report.messages.map((message) => (
<article
key={message.id}
className={`support-message support-message-${message.kind}`}
>
<header>
<strong>
{message.kind === "user" ? (
<Username
name={message.author_name}
appearance={report.authorAppearance}
/>
) : (
t("Support")
)}
</strong>
<small>{formatDate(message.created_at)}</small>
</header>
<p>
{message.kind === "system" ? t(message.body) : message.body}
</p>
{message.kind === "user" && (
<small>
{message.delivered
? t("Delivered to support")
: t("Saved · waiting to send")}
</small>
)}
</article>
))}
</div>
{report.state !== "draft" && (
<form
onSubmit={(event) => {
event.preventDefault();
void send();
}}
>
<label htmlFor="support-message">{t("Message support")}</label>
<textarea
id="support-message"
value={text}
onChange={(event) => setText(event.target.value)}
maxLength={6000}
rows={3}
required
disabled={busy}
/>
<button
className="button primary"
disabled={busy || !text.trim()}
>
<Send size={16} />
{t("Send message")}
</button>
</form>
)}
</>
)}
</div>
);
}

77
components/Username.tsx Normal file
View File

@@ -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 (
<span className="username">
{appearance.role === "moderator" && (
<span
className="username-prefix username-moderator"
title={t("Moderator")}
>
<ShieldCheck size={13} aria-hidden="true" />
{t("Moderator")}
</span>
)}
{appearance.bug_finder && (
<span
className="username-prefix username-bug-finder"
title={t("Bug finder")}
>
<Bug size={13} aria-hidden="true" />
{t("Bug finder")}
</span>
)}
{appearance.custom_prefix && (
<span className="username-prefix username-custom-prefix">
<Icon size={13} aria-hidden="true" />
{appearance.custom_prefix}
</span>
)}
<span
className={enabled ? "username-glow" : undefined}
style={
enabled
? { color, textShadow: `0 0 8px ${color}90, 0 0 18px ${color}60` }
: undefined
}
>
{name}
</span>
</span>
);
}

74
docs/bug-reports.md Normal file
View File

@@ -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).

View File

@@ -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.

View File

@@ -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 });

View File

@@ -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(

304
lib/bug-report-api.ts Normal file
View File

@@ -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<unknown>;
limit: (key: string, count: number, seconds: number) => Promise<void>;
};
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<BugReportView> {
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 });
}

View File

@@ -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],
};

View File

@@ -17,7 +17,7 @@ export async function leaderboard({
limit?: number;
} = {}): Promise<LeaderboardData | null> {
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(

View File

@@ -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<T>(
query: string,
variables: object,
missing = false,
): Promise<T | null> {
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<Issue | null> {
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<Issue> {
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
? `![Player screenshot](${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;
}
}

View File

@@ -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<unknown>,
) {
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 });
}

View File

@@ -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<string, string | number> = {};
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))

View File

@@ -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<string, { name: string; appearance: ProfileAppearance }>();
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;
}

View File

@@ -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(),
);

28
lib/support-images.ts Normal file
View File

@@ -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();
}

22
lib/support-signature.ts Normal file
View File

@@ -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;
}
}

123
lib/support-sync.ts Normal file
View File

@@ -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));
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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";
}

103
shared/bug-reports.ts Normal file
View File

@@ -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, "\\$&");
}

View File

@@ -43,6 +43,30 @@ export type Database = {
};
public: {
Tables: {
bug_reports: {
Row: import("./bug-reports").BugReportRecord;
Insert: Partial<import("./bug-reports").BugReportRecord> &
Pick<
import("./bug-reports").BugReportRecord,
| "client_id"
| "access_hash"
| "display_name"
| "title"
| "description"
>;
Update: Partial<import("./bug-reports").BugReportRecord>;
Relationships: [];
};
bug_report_messages: {
Row: import("./bug-reports").BugMessageRecord;
Insert: Partial<import("./bug-reports").BugMessageRecord> &
Pick<
import("./bug-reports").BugMessageRecord,
"report_id" | "kind" | "body" | "author_name"
>;
Update: Partial<import("./bug-reports").BugMessageRecord>;
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;

View File

@@ -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[];

View File

@@ -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": "Удалите скрытые управляющие символы"
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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<clock_timestamp())
and (retry_at is null or retry_at<clock_timestamp());
return found;
end; $$;
create 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,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;

View File

@@ -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;

218
tests/bug-reports.test.ts Normal file
View File

@@ -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(
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"></svg>',
),
),
/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/,
);
});

View File

@@ -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: "<script>", icon: "star" },
{ prefix: "hi\u202e", icon: "star" },
{ prefix: "Hi", icon: "https://example.com" },
{ prefix: "Hi", icon: "star", role: "moderator" },
])
assert.equal(prefixUpdateSchema.safeParse(body).success, false);
});