Add account based multiplayer hosting and bug report retention

This commit is contained in:
2026-10-10 16:24:34 +05:00
parent 9af71c85c2
commit fc6da6756d
33 changed files with 1538 additions and 102 deletions

View File

@@ -3,6 +3,7 @@ import { z } from "zod";
import { admin, required } from "@/lib/supabase";
import { verifySupportWebhook } from "@/lib/support-signature";
import { supportCommand } from "@/shared/bug-reports";
import { reconcileBugStatus, scheduleBugCleanup } from "@/lib/bug-lifecycle";
export const runtime = "nodejs";
export async function POST(request: NextRequest) {
@@ -59,6 +60,17 @@ export async function POST(request: NextRequest) {
.safeParse(decoded);
if (!payload.success) return new NextResponse(null, { status: 400 });
const event = payload.data;
if (event.type === "Issue" && ["create", "update"].includes(event.action)) {
try {
await reconcileBugStatus(event.data.id, true);
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json(
{ error: "Support status delivery failed" },
{ status: 503 },
);
}
}
// Only explicit new staff comments are public. Internal comments and user echoes stay private.
if (
event.action !== "create" ||
@@ -71,6 +83,16 @@ export async function POST(request: NextRequest) {
const command = supportCommand(event.data.body ?? "");
if (!command) return NextResponse.json({ ok: true });
try {
if (command.kind === "close") {
required(
await admin().rpc("receive_bug_close", {
p_report: event.data.issueId,
p_external: event.data.id,
}),
);
await scheduleBugCleanup(event.data.issueId);
return NextResponse.json({ ok: true });
}
required(
await admin().rpc("receive_bug_reply", {
p_report: event.data.issueId,

View File

@@ -2,6 +2,7 @@ import { getI18n } from "@/lib/i18n";
import { Header, Footer } from "@/components/Shell";
import { DraftEditor } from "@/components/DraftEditor";
import { user } from "@/lib/supabase";
import { accountProfile } from "@/lib/profile";
import { redirect } from "next/navigation";
export default async function Draft({
params,
@@ -10,15 +11,17 @@ export default async function Draft({
}) {
const { t } = await getI18n();
const { id } = await params;
if (!(await user()))
const account = await user();
if (!account)
redirect(`/log-in?next=${encodeURIComponent("/dashboard/" + id)}`);
const profile = await accountProfile(account);
return (
<>
<Header />
<main className="section page-content">
<span className="eyebrow">{t("YOUR WORLD · YOUR LOCATIONS")}</span>
<h1>{t("Map workshop")}</h1>
<DraftEditor id={id} />
<DraftEditor id={id} hostName={profile.display_name} />
</main>
<Footer />
</>

View File

@@ -8,6 +8,7 @@ import { getMap } from "@/lib/catalog";
import { ContributionInstructions } from "@/components/ContributionInstructions";
import { ReportContent } from "@/components/ReportContent";
import { user } from "@/lib/supabase";
import { accountProfile } from "@/lib/profile";
import { ExportMap } from "@/components/MapTransfer";
export default async function ServerPage({
params,
@@ -19,6 +20,7 @@ export default async function ServerPage({
const map = await getMap(id);
if (!map) notFound();
const account = await user();
const profile = account ? await accountProfile(account) : null;
return (
<>
<Header />
@@ -63,7 +65,7 @@ export default async function ServerPage({
</span>
</div>
</div>
<StartGame datasetId={id} />
<StartGame datasetId={id} hostName={profile?.display_name ?? null} />
</div>
{map.allow_contributions && (
<ContributionInstructions

View File

@@ -36,7 +36,13 @@ type Draft = {
banned_at: string | null;
ban_reason: string | null;
};
export function DraftEditor({ id }: { id: string }) {
export function DraftEditor({
id,
hostName,
}: {
id: string;
hostName: string;
}) {
const { t } = useI18n();
const dirty = useRef(false);
const [draft, setDraft] = useState<Draft | null>(null),
@@ -180,7 +186,9 @@ export function DraftEditor({ id }: { id: string }) {
"This shared map is private and playable without moderator review. Only the original owner can submit it for public review.",
)}
</p>
{draft.state === "draft" && <StartGame datasetId={id} />}
{draft.state === "draft" && (
<StartGame datasetId={id} hostName={hostName} />
)}
</section>
)}
{!draft.parent_dataset_id &&

View File

@@ -2,16 +2,22 @@
import { useI18n } from "@/components/I18nProvider";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowRight, Timer, ZoomIn, Users } from "lucide-react";
import { difficulties, type Difficulty } from "@/shared/protocol";
import { api } from "@/lib/client-api";
export { api } from "@/lib/client-api";
export function StartGame({ datasetId }: { datasetId: string }) {
export function StartGame({
datasetId,
hostName,
}: {
datasetId: string;
hostName: string | null;
}) {
const { t } = useI18n();
const [difficulty, setDifficulty] = useState<Difficulty>("normal"),
[mode, setMode] = useState<"solo" | "multiplayer">("solo"),
[displayName, setDisplayName] = useState(""),
[playerLimit, setPlayerLimit] = useState(4),
[loading, setLoading] = useState(false),
[error, setError] = useState("");
@@ -25,7 +31,7 @@ export function StartGame({ datasetId }: { datasetId: string }) {
{
datasetId,
difficulty,
...(mode === "multiplayer" ? { displayName, playerLimit } : {}),
...(mode === "multiplayer" ? { playerLimit } : {}),
},
);
router.push((mode === "solo" ? "/play/" : "/multiplayer/") + session.id);
@@ -82,17 +88,16 @@ export function StartGame({ datasetId }: { datasetId: string }) {
</p>
{mode === "multiplayer" && (
<div className="multiplayer-settings">
<label htmlFor="room-display-name">{t("Your player name")}</label>
<input
id="room-display-name"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
minLength={2}
maxLength={32}
placeholder={t("Enter a player name")}
autoComplete="nickname"
disabled={loading}
/>
{hostName ? (
<p>
{t("Host display name")}: <strong>{hostName}</strong>
</p>
) : (
<p className="muted">
{t("Log in to create a multiplayer room")}{" "}
<Link href="/log-in?next=/servers">{t("Log in")}</Link>
</p>
)}
<label htmlFor="room-player-limit">
{t("Player limit (including you)")}
</label>
@@ -118,9 +123,7 @@ export function StartGame({ datasetId }: { datasetId: string }) {
<button
className="button primary full"
onClick={start}
disabled={
loading || (mode === "multiplayer" && displayName.trim().length < 2)
}
disabled={loading || (mode === "multiplayer" && !hostName)}
>
{loading
? t("Preparing your world…")

View File

@@ -218,7 +218,10 @@ export function SupportInbox({ pagePath }: { pagePath: string }) {
{inbox.reports.map((report) => (
<li key={report.id}>
<Link href={`/support/${report.id}`}>
<strong>{report.title}</strong>
<strong>
{report.title}
{report.state === "done" && <> · {t("Done")}</>}
</strong>
<small>
{formatDate(report.created_at)} ·{" "}
{report.linear_identifier ??
@@ -331,6 +334,47 @@ export function SupportConversation({ id }: { id: string }) {
{report.linear_identifier ?? t("Waiting for support")}
</span>
<h1>{report.title}</h1>
{report.state === "done" && (
<div role="status">
<p>
<strong>{t("Done")}</strong> ·{" "}
{t(
"This report is closed and will be deleted from MineGeo and Linear after three days.",
)}
</p>
{report.delete_after && (
<p>
{t("Scheduled deletion")}: {formatDate(report.delete_after)}
</p>
)}
{!report.linear_done_synced && (
<p>{t("Syncing Done status with Linear…")}</p>
)}
{report.cleanup_error && (
<p className="error-message">
{t(
"Support cleanup paused. The support team may need to update Linear permissions.",
)}{" "}
<button
className="text-link"
disabled={busy}
onClick={() => void action("retry-cleanup", {})}
>
{t("Retry")}
</button>
</p>
)}
</div>
)}
{report.state === "open" && (
<button
className="button"
disabled={busy}
onClick={() => void action("close", {})}
>
{t("Close report")}
</button>
)}
{report.state === "pending" && (
<p role="status">
{t("Your report is saved. We’re connecting it to support.")}
@@ -432,7 +476,7 @@ export function SupportConversation({ id }: { id: string }) {
</article>
))}
</div>
{report.state !== "draft" && (
{report.state !== "draft" && report.state !== "done" && (
<form
onSubmit={(event) => {
event.preventDefault();

View File

@@ -24,6 +24,20 @@ 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.
Players can select **Close report** after support has received it. The conversation
shows **Done**, becomes read-only, and displays its deletion date. MineGeo moves
the matching Linear issue to the team's **Done** status. Staff can also post the
exact command `#player-report-close`, or move the issue to Done in Linear.
Three days after closing, a durable Vercel Workflow deletes the Linear issue,
both the original and normalized Supabase screenshots, the local report, and its
messages. It runs even when nobody visits support. Linear's normal deleted-issue
trash retention still applies. Username glow and Bug finder rewards already
granted to an account remain. Guest rewards must be linked before the report is
deleted. Reopening the issue in Linear before cleanup starts cancels deletion;
closing it again starts a new three-day period. Repeated close requests and
replayed staff commands do not extend the existing deadline.
To grant or remove the actual account reward, add a separate new comment:
```text
@@ -49,17 +63,21 @@ badges by copying a registered player's display name.
## Deployment setup
Apply `20261008071054_bug_reports.sql` and `20261008075322_profile_identity.sql`
Apply `20261008071054_bug_reports.sql`, `20261008075322_profile_identity.sql`, and
`20261010053310_bug_report_retention.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_API_KEY`: sensitive personal API key with **Read and Write** access to
the support team. Create issues/comments alone cannot update status or delete
issues. If needed, create a replacement in Linear **Settings → Security & access
→ Personal API keys**, then replace this variable in Vercel **Production** and
redeploy. Never paste the key into chat.
- `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
Enable **Comment and Issue** 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.
@@ -68,7 +86,18 @@ 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.
Issue events immediately synchronize Done/reopened states. Opening the inbox or
conversation also checks status, at most once per minute per report, as a fallback.
If Linear or Storage deletion fails, the local report is retained for retries;
the conversation shows a retry action if cleanup exhausts its automatic retries.
Keep Vercel's **Enable access to System Environment Variables** setting enabled
for durable timers. No additional secret or cron is needed.
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).
`node scripts/test-bug-retention-http.mjs https://minegeo.vercel.app` verifies
the deployed flow with a clearly labeled temporary report and screenshot. It
checks ownership, Done synchronization, the three-day deadline, and deletion
from both systems by advancing only that test report's deadline. The fixture ID
is journaled under the ignored `.data/retention-check` directory for recovery.

View File

@@ -11,6 +11,10 @@ and re-publication do not create new-map announcements. Existing maps from befor
this integration are not backfilled.
In an open multiplayer lobby, the host can select **Publish party to Discord**.
Hosting requires a signed-in account and always uses its stored Display Name.
Signed-in players join with account names; guests can join with nicknames.
If the host leaves, another signed-in player takes over; the party closes if
only guests remain.
All published parties go to the single server-wide `DISCORD_PARTIES_CHANNEL_ID`.
Players cannot choose a destination. Discord readers can open the invite and join
without a MineGeo account while the lobby is open and has space. Messages show

View File

@@ -60,6 +60,7 @@ additional Supabase credentials.
Multiplayer uses the Supabase migration described in [multiplayer.md](multiplayer.md).
Apply that migration to a new database before deploying the multiplayer web files.
Room chat also requires `20261007155622_multiplayer_chat.sql` before the web update.
Account-only party hosting requires `20261010053429_multiplayer_account_hosts.sql`.
Map ZIP sharing requires `20261007145000_map_sharing.sql`. Imported maps stay
private and can be played without moderation; publication is blocked. See
@@ -70,6 +71,10 @@ The script targets the existing MineGeo project. Set `VERCEL_PROJECT_ID` and
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.
Comment and Issue webhook events before deploying so staff replies, glow commands,
and status changes reach players. Report retention also requires
`20261010053310_bug_report_retention.sql` and a Linear API key with Read and Write
permissions. Closed reports move to Done and are deleted from MineGeo and Linear
after three days through a durable workflow; no cron job is required.
Glow colors and user prefixes also require `20261008075322_profile_identity.sql`
before deploying the updated account, catalog, and multiplayer pages.

146
lib/bug-cleanup-workflow.ts Normal file
View File

@@ -0,0 +1,146 @@
import {
FatalError,
RetryableError,
getWorkflowMetadata,
sleep,
} from "workflow";
import { admin, required } from "./supabase";
import {
LinearSupportClient,
LinearPermissionError,
} from "./linear-support-client";
import { purgeClosedBugReport } from "./bug-retention";
const linearClient = () =>
new LinearSupportClient(
process.env.LINEAR_API_KEY!,
process.env.LINEAR_TEAM_ID!,
process.env.LINEAR_PROJECT_ID,
);
async function record(id: string) {
const result = await admin()
.from("bug_reports")
.select("*")
.eq("id", id)
.maybeSingle();
if (result.error) required(result);
return result.data;
}
export async function cleanupDoneBugReport(
id: string,
closedAt: string,
claim: string,
) {
"use workflow";
const run = getWorkflowMetadata().workflowRunId;
try {
const deadline = await markLinearDone(id, closedAt);
if (deadline) {
await sleep(new Date(deadline));
await purgeDone(id, closedAt);
}
} catch {
await releaseCleanup(id, closedAt, claim, run);
throw new Error("Bug report cleanup paused; the saved report will retry.");
}
}
async function markLinearDone(
id: string,
closedAt: string,
): Promise<string | null> {
"use step";
try {
const report = await record(id);
if (!report || report.state !== "done" || report.closed_at !== closedAt)
return null;
if (!report.linear_done_synced) {
await linearClient().done(id);
required(
await admin()
.from("bug_reports")
.update({ linear_done_synced: true, cleanup_error: null })
.eq("id", id)
.eq("state", "done")
.eq("closed_at", closedAt)
.select("id"),
);
}
return report.delete_after ?? null;
} catch (error) {
if (error instanceof LinearPermissionError)
throw new FatalError("The Linear key needs Write permission.");
throw new RetryableError(
"Could not move the Linear report to Done. Check the API key's Write permission.",
{ retryAfter: "1m" },
);
}
}
markLinearDone.maxRetries = 10;
async function purgeDone(id: string, closedAt: string) {
"use step";
try {
await purgeClosedBugReport(id, closedAt, {
record: () => record(id),
linear: linearClient(),
begin: async () =>
required(
await admin().rpc("begin_bug_purge", {
p_report: id,
p_closed_at: closedAt,
}),
),
reopen: async () => {
required(
await admin().rpc("set_bug_report_done", {
p_report: id,
p_done: false,
}),
);
},
removeFiles: async (paths) => {
required(await admin().storage.from("minegeo-support").remove(paths));
},
finish: async () => {
required(
await admin().rpc("finish_bug_purge", {
p_report: id,
p_closed_at: closedAt,
}),
);
},
});
} catch {
throw new RetryableError(
"Report deletion could not be confirmed. The saved record will retry.",
{ retryAfter: "5m" },
);
}
}
purgeDone.maxRetries = 10;
async function releaseCleanup(
id: string,
closedAt: string,
claim: string,
run: string,
) {
"use step";
required(
await admin()
.from("bug_reports")
.update({
cleanup_run_id: null,
cleanup_lease_until: new Date(Date.now() + 60000).toISOString(),
cleanup_error:
"Support cleanup paused. Check Linear Write permission and retry.",
})
.eq("id", id)
.eq("closed_at", closedAt)
.in("cleanup_run_id", [claim, run])
.select("id"),
);
console.error("Bug report cleanup paused", { id, run });
}
releaseCleanup.maxRetries = 10;

98
lib/bug-lifecycle.ts Normal file
View File

@@ -0,0 +1,98 @@
import "server-only";
import { randomUUID } from "node:crypto";
import { start } from "workflow/api";
import { admin, required } from "./supabase";
import { LinearSupportClient } from "./linear-support-client";
import { cleanupDoneBugReport } from "./bug-cleanup-workflow";
export async function scheduleBugCleanup(id: string) {
if (!process.env.LINEAR_API_KEY || !process.env.LINEAR_TEAM_ID) return;
const db = admin(),
claim = `starting:${randomUUID()}`;
const rows = required(
await db.rpc("claim_bug_cleanup", { p_report: id, p_worker: claim }),
);
for (const report of rows) {
try {
const run = await start(cleanupDoneBugReport, [
id,
report.closed_at!,
claim,
]);
required(
await db
.from("bug_reports")
.update({ cleanup_run_id: run.runId, cleanup_lease_until: null })
.eq("id", id)
.eq("cleanup_run_id", claim)
.select("id"),
);
} catch {
console.error("Bug report cleanup remains saved for retry.");
}
}
}
export async function reconcileBugStatus(id: string, force = false) {
const db = admin();
const lookup = await db
.from("bug_reports")
.select("*")
.eq("id", id)
.maybeSingle();
if (lookup.error) required(lookup);
const report = lookup.data;
if (!report?.issue_synced || report.purge_started_at) return;
if (report.state === "done" && !report.linear_done_synced) {
await scheduleBugCleanup(id);
return;
}
if (
!force &&
report.status_checked_at &&
Date.parse(report.status_checked_at) > Date.now() - 60000
)
return;
required(
await db
.from("bug_reports")
.update({ status_checked_at: new Date().toISOString() })
.eq("id", id)
.select("id"),
);
const linear = new LinearSupportClient(
process.env.LINEAR_API_KEY!,
process.env.LINEAR_TEAM_ID!,
process.env.LINEAR_PROJECT_ID,
);
const issue = await linear.issue(id);
if (!issue) {
if (report.state === "done") await scheduleBugCleanup(id);
return;
}
if (issue.state?.type === "completed") {
required(
await db.rpc("set_bug_report_done", {
p_report: id,
p_done: true,
p_closed_at: issue.completedAt ?? null,
}),
);
// An external completed status may need normalizing to the team's Done status.
required(
await db
.from("bug_reports")
.update({
linear_done_synced: issue.state.name.toLowerCase() === "done",
})
.eq("id", id)
.eq("state", "done")
.select("id"),
);
await scheduleBugCleanup(id);
} else if (report.state === "done") {
required(
await db.rpc("set_bug_report_done", { p_report: id, p_done: false }),
);
}
}

View File

@@ -2,6 +2,7 @@ import "server-only";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { after } from "next/server";
import { admin, required, user } from "./supabase";
import { accountProfile } from "./profile";
import { requestOrigin } from "./request-origin";
@@ -14,6 +15,7 @@ import {
} from "../shared/bug-reports";
import { supportScreenshot } from "./support-images";
import { supportReady, syncBugAfterResponse } from "./support-sync";
import { scheduleBugCleanup, reconcileBugStatus } from "./bug-lifecycle";
type Dependencies = {
jsonBody: (request: NextRequest) => Promise<unknown>;
@@ -51,9 +53,14 @@ export async function bugReportApi(
? 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(),
);
const result = await db
.from("bug_reports")
.select("*")
.eq("id", id)
.maybeSingle();
if (result.error) required(result);
const record = result.data;
if (!record) throw new ServiceError("Report not found", 404);
if (!owns(record)) throw new ServiceError("Report not found", 404);
return record;
}
@@ -79,6 +86,10 @@ export async function bugReportApi(
description: record.description,
page_path: record.page_path,
state: record.state,
closed_at: record.closed_at,
delete_after: record.delete_after,
linear_done_synced: record.linear_done_synced,
cleanup_error: record.cleanup_error,
linear_identifier: record.linear_identifier,
glow_requested: record.glow_requested,
created_at: record.created_at,
@@ -102,6 +113,12 @@ export async function bugReportApi(
.order("created_at", { ascending: false })
.limit(50),
);
after(async () => {
for (let i = 0; i < rows.length; i += 5)
await Promise.allSettled(
rows.slice(i, i + 5).map((row) => reconcileBugStatus(row.id)),
);
});
return respond({
ready: supportReady(),
signedIn: !!account,
@@ -179,8 +196,14 @@ export async function bugReportApi(
const id = z.string().uuid().parse(path[1]);
const record = await owned(id);
if (path.length === 2 && request.method === "GET") {
try {
await reconcileBugStatus(id);
} catch {
console.error("Bug report status will retry.");
}
if (record.state !== "draft") syncBugAfterResponse(id);
return respond(await view(record));
if (record.state === "done") await scheduleBugCleanup(id);
return respond(await view(await owned(id)));
}
if (path.length !== 3 || request.method !== "POST")
return NextResponse.json({ error: "Not found" }, { status: 404 });
@@ -243,6 +266,8 @@ export async function bugReportApi(
return respond(await view(await owned(id)));
}
if (path[2] === "messages") {
if (record.state === "done")
throw new ServiceError("This bug report is Done", 409);
if (record.state === "draft")
throw new ServiceError("Submit this report before sending messages", 409);
const body = bugMessageSchema.parse(await jsonBody(request));
@@ -289,6 +314,36 @@ export async function bugReportApi(
syncBugAfterResponse(id);
return respond(await view(record));
}
if (path[2] === "close") {
z.object({})
.strict()
.parse(await jsonBody(request));
if (!record.issue_synced)
throw new ServiceError(
"Wait for support to receive this report before closing it",
409,
);
required(
await db.rpc("set_bug_report_done", { p_report: id, p_done: true }),
);
await scheduleBugCleanup(id);
return respond(await view(await owned(id)));
}
if (path[2] === "retry-cleanup" && record.state === "done") {
z.object({})
.strict()
.parse(await jsonBody(request));
required(
await db
.from("bug_reports")
.update({ cleanup_lease_until: null })
.eq("id", id)
.is("cleanup_run_id", null)
.select("id"),
);
await scheduleBugCleanup(id);
return respond(await view(await owned(id)));
}
if (path[2] === "claim") {
if (!account) throw new ServiceError("Sign in to link this report", 401);
required(

38
lib/bug-retention.ts Normal file
View File

@@ -0,0 +1,38 @@
import type { BugReportRecord } from "../shared/bug-reports";
import type { LinearSupportClient } from "./linear-support-client";
export async function purgeClosedBugReport(
id: string,
closedAt: string,
deps: {
record: () => Promise<BugReportRecord | null>;
linear: Pick<LinearSupportClient, "issue" | "remove">;
begin: () => Promise<boolean>;
reopen: () => Promise<void>;
removeFiles: (paths: string[]) => Promise<void>;
finish: () => Promise<void>;
now?: () => number;
},
) {
const report = await deps.record();
if (
!report ||
report.state !== "done" ||
report.closed_at !== closedAt ||
!report.delete_after ||
Date.parse(report.delete_after) > (deps.now ?? Date.now)()
)
return;
if (!report.purge_started_at) {
const issue = await deps.linear.issue(id);
if (issue && issue.state?.type !== "completed") {
await deps.reopen();
return;
}
}
if (!(await deps.begin())) return;
// Keep the local retry record until BOTH external deletions are confirmed.
await deps.linear.remove(id);
await deps.removeFiles([`${id}/input`, `${id}/screenshot.webp`]);
await deps.finish();
}

View File

@@ -83,16 +83,27 @@ async function announcementSource(
const players = required(
await db
.from("multiplayer_players")
.select("id,display_name")
.select("id,display_name,account_id")
.eq("room_id", room.id)
.eq("has_left", false),
);
const host = players.find((p) => p.id === room.host_player_id);
if (!host || players.length >= room.player_limit) return null;
let hostName = host.display_name;
if (host.account_id) {
const profile = required(
await db
.from("profiles")
.select("display_name")
.eq("id", host.account_id)
.single(),
);
hostName = profile.display_name;
}
return {
id: room.id,
name: map.data.name,
owner: host.display_name,
owner: hostName,
difficulty: room.difficulty,
players: players.length,
limit: room.player_limit,

View File

@@ -3,7 +3,14 @@ import {
type BugReportRecord,
} from "../shared/bug-reports";
type Issue = { id: string; identifier: string; team: { id: string } };
type Issue = {
id: string;
identifier: string;
team: { id: string };
state?: { id: string; name: string; type: string };
completedAt?: string | null;
};
export class LinearPermissionError extends Error {}
export class LinearSupportClient {
constructor(
private key: string,
@@ -34,13 +41,26 @@ export class LinearSupportClient {
)
)
return null;
if (
response.status === 401 ||
response.status === 403 ||
result.errors?.some(
(error: { message?: string; extensions?: { code?: string } }) =>
/forbidden|permission|authentication|unauthorized/i.test(
`${error.extensions?.code ?? ""} ${error.message ?? ""}`,
),
)
)
throw new LinearPermissionError(
"Support delivery is temporarily unavailable. Check Linear Write permission.",
);
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 } } }",
"query SupportIssue($id: String!) { issue(id: $id) { id identifier team { id } state { id name type } completedAt } }",
{ id },
true,
);
@@ -49,6 +69,47 @@ export class LinearSupportClient {
throw new Error("Support issue team mismatch");
return issue;
}
async done(id: string) {
const issue = await this.issue(id);
if (!issue) throw new Error("Support issue was not found");
if (
issue.state?.type === "completed" &&
issue.state.name.toLowerCase() === "done"
)
return;
const data = await this.graphql<{
team: { states: { nodes: { id: string; name: string; type: string }[] } };
}>(
"query SupportDoneState($team: String!) { team(id:$team) { states { nodes { id name type } } } }",
{ team: this.team },
);
const state = data?.team.states.nodes.find(
(s) => s.type === "completed" && s.name.toLowerCase() === "done",
);
if (!state)
throw new Error("The support team needs a Done workflow status");
const result = await this.graphql<{
issueUpdate: { success: boolean; issue: Issue };
}>(
"mutation SupportDone($id: String!, $input: IssueUpdateInput!) { issueUpdate(id:$id,input:$input) { success issue { id state { id name type } } } }",
{ id, input: { stateId: state.id } },
);
if (
!result?.issueUpdate.success ||
result.issueUpdate.issue.id !== id ||
result.issueUpdate.issue.state?.id !== state.id
)
throw new Error("Support issue status was not confirmed");
}
async remove(id: string) {
if (!(await this.issue(id))) return;
const result = await this.graphql<{ issueDelete: { success: boolean } }>(
"mutation SupportDelete($id: String!) { issueDelete(id:$id) { success } }",
{ id },
);
if (!result?.issueDelete.success)
throw new Error("Support issue deletion was not confirmed");
}
async createIssue(report: BugReportRecord): Promise<Issue> {
const existing = await this.issue(report.id);
if (existing) return existing;
@@ -65,6 +126,7 @@ export class LinearSupportClient {
"",
"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.",
"Close this report with #player-report-close or move the issue to Done. Closed reports and issues are deleted after three days.",
`Report reference: ${report.id}`,
].join("\n");
try {

View File

@@ -61,30 +61,22 @@ export async function multiplayerApi(
return response;
};
if (request.method === "POST" && path.length === 1) {
const account = await user();
if (!account)
throw new ServiceError("Log in to create a multiplayer room", 401);
const body = createRoomSchema.parse(await jsonBody(request));
await limit("multiplayer:create:" + accessHash, 20, 3600);
const account = await user();
if (account) await accountProfile(account);
const profile = await accountProfile(account);
const id = required(
await client.rpc("create_map_room", {
p_dataset: body.datasetId,
p_difficulty: body.difficulty,
p_name: body.displayName,
p_name: profile.display_name,
p_limit: body.playerLimit,
p_access_hash: accessHash,
p_owner: account?.id ?? null,
p_owner: account.id,
}),
);
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]);
@@ -151,11 +143,18 @@ export async function multiplayerApi(
});
}
let payload: Record<string, string | number> = {};
let joiningAccount: string | null = null;
if (request.method === "POST" && path.length === 3) {
const input = await jsonBody(request);
if (action === "join") {
payload = { name: joinRoomSchema.parse(input).displayName };
await limit("multiplayer:join:" + accessHash, 40, 3600);
const account = await user();
if (account) {
const profile = await accountProfile(account);
payload.name = profile.display_name;
joiningAccount = account.id;
}
} else if (action === "guess") payload = roomGuessSchema.parse(input);
else if (action === "ready" || action === "next")
payload = roomRoundSchema.parse(input);
@@ -172,12 +171,19 @@ export async function multiplayerApi(
return NextResponse.json({ error: "Not found" }, { status: 404 });
const room = required(
await client.rpc("multiplayer_action", {
p_room: id,
p_access_hash: accessHash,
p_action: request.method === "GET" ? "view" : action,
p_payload: payload,
}),
action === "join"
? await client.rpc("join_multiplayer_room", {
p_room: id,
p_access_hash: accessHash,
p_owner: joiningAccount,
p_name: String(payload.name),
})
: await client.rpc("multiplayer_action", {
p_room: id,
p_access_hash: accessHash,
p_action: request.method === "GET" ? "view" : action,
p_payload: payload,
}),
) as unknown as RoomView;
if (room.status === "closed") {
try {
@@ -186,22 +192,6 @@ export async function multiplayerApi(
console.error("Closed Discord party remains queued for cleanup.");
}
}
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,

View File

@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
import { after } from "next/server";
import { admin, required } from "./supabase";
import { LinearSupportClient } from "./linear-support-client";
import { scheduleBugCleanup } from "./bug-lifecycle";
export function supportReady() {
return !!(
@@ -58,13 +59,20 @@ export async function syncBugReport(id: string) {
.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_reports")
.update({ state: "open" })
.eq("id", id)
.eq("state", "pending")
.select("id"),
);
}
required(
await db
@@ -102,6 +110,7 @@ export async function syncBugReport(id: string) {
.eq("lease_owner", worker)
.select("id"),
);
await scheduleBugCleanup(id);
} catch {
console.error("Bug report delivery paused; the saved report will retry.");
required(

View File

@@ -248,6 +248,133 @@ try {
).rows[0];
assert.equal(claimed.bug_finder, true);
assert.equal(claimed.name_glow, false);
const closeEvent = randomUUID();
await db.query("select public.receive_bug_close($1,$2)", [
report,
closeEvent,
]);
let closed = (
await db.query("select * from public.bug_reports where id=$1", [report])
).rows[0];
assert.equal(closed.state, "done");
assert.equal(
new Date(closed.delete_after) - new Date(closed.closed_at),
3 * 86400000,
);
await db.query("select public.set_bug_report_done($1,true,null)", [report]);
assert.equal(
String(
(
await db.query("select closed_at from public.bug_reports where id=$1", [
report,
])
).rows[0].closed_at,
),
String(closed.closed_at),
"repeat close preserves deadline",
);
assert.equal(
(
await db.query("select public.begin_bug_purge($1,$2) as ok", [
report,
closed.closed_at,
])
).rows[0].ok,
false,
"cannot purge before three days",
);
await assert.rejects(
db.query(
"insert into public.bug_report_messages(report_id,kind,body,author_name) values($1,'user','new message','Guest')",
[report],
),
/Done/,
);
await db.query("select public.set_bug_report_done($1,false,null)", [report]);
assert.equal(
(
await db.query("select public.receive_bug_close($1,$2) as ok", [
report,
closeEvent,
])
).rows[0].ok,
false,
"replayed close command cannot reclose a reopened report",
);
await db.query(
"select public.set_bug_report_done($1,true,clock_timestamp()-interval '4 days')",
[report],
);
closed = (
await db.query("select * from public.bug_reports where id=$1", [report])
).rows[0];
assert.equal(
(
await db.query("select public.begin_bug_purge($1,$2) as ok", [
report,
closed.closed_at,
])
).rows[0].ok,
false,
"Linear completion must be confirmed",
);
await db.query(
"update public.bug_reports set linear_done_synced=true where id=$1",
[report],
);
assert.equal(
(
await db.query("select public.begin_bug_purge($1,$2) as ok", [
report,
closed.closed_at,
])
).rows[0].ok,
true,
);
assert.equal(
(
await db.query("select public.set_bug_report_done($1,false,null) as ok", [
report,
])
).rows[0].ok,
false,
"cannot reopen midway through irreversible purge",
);
await db.query("select public.finish_bug_purge($1,$2)", [
report,
closed.closed_at,
]);
assert.equal(
(
await db.query(
"select count(*)::int as n from public.bug_report_messages where report_id=$1",
[report],
)
).rows[0].n,
0,
"messages cascade on purge",
);
assert.equal(
(
await db.query("select bug_finder from public.profiles where id=$1", [
account,
])
).rows[0].bug_finder,
true,
"account recognition survives report deletion",
);
for (const role of ["anon", "authenticated"]) {
await db.exec(`set role ${role}`);
await assert.rejects(
db.query("select public.set_bug_report_done($1,true,null)", [guest]),
/permission denied/,
);
await assert.rejects(
db.query("select public.finish_bug_purge($1,now())", [guest]),
/permission denied/,
);
await db.exec("reset role");
}
console.log(
"Bug report SQL passed: private tables, lease retries, guest claims, staff reply deduplication, grant/revoke rewards and privilege isolation.",
);

View File

@@ -0,0 +1,146 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import { createClient } from "@supabase/supabase-js";
import sharp from "sharp";
process.loadEnvFile(".env.local");
const origin = new URL(process.argv[2] || "https://minegeo.vercel.app").origin;
const db = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.SUPABASE_SECRET_KEY,
{ auth: { persistSession: false, autoRefreshToken: false } },
);
const checked = (r) => {
if (r.error) throw new Error(r.error.message);
return r.data;
};
const cookies = new Map();
let id;
await mkdir(".data/retention-check", { recursive: true });
async function api(path, body, expected = 200, stranger = false) {
const response = await fetch(`${origin}/api/v1/bug-reports${path}`, {
method: body === undefined ? "GET" : "POST",
headers: {
origin,
"content-type": "application/json",
cookie: stranger
? ""
: [...cookies].map(([k, v]) => `${k}=${v}`).join("; "),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
signal: AbortSignal.timeout(60000),
});
if (!stranger)
for (const cookie of response.headers.getSetCookie()) {
const part = cookie.split(";")[0],
at = part.indexOf("=");
cookies.set(part.slice(0, at), part.slice(at + 1));
}
const result = await response.json();
assert.equal(response.status, expected, `${path}: ${JSON.stringify(result)}`);
return result;
}
async function row() {
return checked(
await db.from("bug_reports").select("*").eq("id", id).maybeSingle(),
);
}
async function waitFor(predicate, label) {
for (let n = 0; n < 40; n++) {
const current = await row();
if (predicate(current)) return current;
if (current?.cleanup_error) throw new Error(current.cleanup_error);
await new Promise((r) => setTimeout(r, 3000));
}
throw new Error(`Timed out: ${label}`);
}
assert.equal((await api("")).ready, true);
const created = await api("", {
clientId: randomUUID(),
title: `[Verification] Retention ${randomUUID()}`,
description:
"Automated temporary verification fixture for three-day report retention. This is not a real player bug.",
displayName: "Support verification",
pagePath: "/support",
screenshot: true,
});
id = created.id;
await writeFile(
".data/retention-check/fixture.json",
JSON.stringify({ id, cookies: [...cookies] }),
);
const image = await sharp({
create: { width: 8, height: 8, channels: 3, background: "#334455" },
})
.png()
.toBuffer();
assert.equal(
(
await fetch(created.upload, {
method: "PUT",
headers: { "content-type": "image/png" },
body: image,
})
).ok,
true,
);
await api(`/${id}/submit`, {});
await waitFor((r) => r?.issue_synced, "Linear issue creation");
await api(`/${id}/close`, {}, 404, true);
const closed = await api(`/${id}/close`, {});
assert.equal(closed.state, "done");
assert.equal(
Date.parse(closed.delete_after) - Date.parse(closed.closed_at),
3 * 86400000,
);
assert.equal((await api(`/${id}/close`, {})).delete_after, closed.delete_after);
await api(
`/${id}/messages`,
{ clientId: randomUUID(), text: "Blocked after closure" },
409,
);
await waitFor((r) => r?.linear_done_synced, "Linear Done status");
console.log(
JSON.stringify({
fixture: id,
checkpoint: "Done synchronized; three-day deadline and ownership verified",
}),
);
// Accelerate only this journaled verification fixture; no real report is modified.
const backdated = new Date(Date.now() - 3 * 86400000 - 60000).toISOString();
checked(
await db
.from("bug_reports")
.update({
closed_at: backdated,
delete_after: new Date(Date.now() - 60000).toISOString(),
cleanup_run_id: null,
cleanup_lease_until: null,
status_checked_at: new Date().toISOString(),
})
.eq("id", id)
.like("title", "[Verification] Retention %"),
);
await api(`/${id}/retry-cleanup`, {});
await waitFor((r) => r === null, "both-system deletion");
assert.equal(
checked(await db.from("bug_report_messages").select("id").eq("report_id", id))
.length,
0,
);
assert.equal(
checked(await db.storage.from("minegeo-support").list(id)).length,
0,
);
await api(`/${id}`, undefined, 404);
await writeFile(
".data/retention-check/fixture.json",
JSON.stringify({ id, deleted: true }),
);
console.log(
JSON.stringify({
fixture: id,
checkpoint:
"Expired report, messages, screenshots and Linear issue deleted",
}),
);

View File

@@ -112,18 +112,21 @@ try {
.limit(1)
.single(),
);
const host = session(),
const host = await account("contributor"),
friend = session(),
outsider = session();
const created = await host("rooms", {
datasetId: dataset.id,
difficulty: "normal",
displayName: "Discord host fixture",
playerLimit: 4,
});
rooms.push(created.id);
await journal();
const room = await host(`rooms/${created.id}`);
assert.equal(
room.players.find((p) => p.id === room.hostId).name,
"Discord contributor fixture",
);
assert.equal(typeof room.discord.available, "boolean");
assert.equal(room.discord.state, null);
await outsider(`rooms/${created.id}/discord`, {}, "POST", 403);

View File

@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { randomUUID, randomBytes } from "node:crypto";
import fs from "node:fs";
import { chromium, expect } from "@playwright/test";
import { createClient } from "@supabase/supabase-js";
@@ -9,7 +10,8 @@ const db = createClient(
process.env.SUPABASE_SECRET_KEY,
{ auth: { persistSession: false, autoRefreshToken: false } },
);
const rooms = [];
const rooms = [],
accounts = [];
const browser = await chromium.launch({
headless: true,
args: ["--use-angle=swiftshader", "--enable-unsafe-swiftshader"],
@@ -34,6 +36,21 @@ try {
});
const host = await desktop.newPage(),
friend = await mobile.newPage();
const email = `multiplayer-browser-${randomUUID()}@example.com`,
password = randomBytes(24).toString("base64url");
const fixture = await db.auth.admin.createUser({
email,
password,
email_confirm: true,
user_metadata: { display_name: "Browser Host" },
});
if (fixture.error) throw fixture.error;
accounts.push(fixture.data.user.id);
const loggedIn = await desktop.request.post(`${origin}/api/v1/auth`, {
headers: { origin },
data: { action: "login", email, password },
});
assert.equal(loggedIn.status(), 200);
const pageErrors = [];
for (const page of [host, friend])
page.on("pageerror", (error) => pageErrors.push(error.message));
@@ -42,7 +59,10 @@ try {
timeout: 60000,
});
await host.getByRole("button", { name: "Multiplayer", exact: true }).click();
await host.getByLabel("Your player name").fill("Browser Host");
await expect(host.locator(".multiplayer-settings")).toContainText(
"Browser Host",
);
await expect(host.getByLabel("Your player name")).toHaveCount(0);
await host.getByLabel("Player limit (including you)").selectOption("2");
await host
.getByRole("button", { name: "Create multiplayer room", exact: true })
@@ -248,4 +268,10 @@ try {
if (error)
throw new Error(`Browser test-room cleanup failed: ${error.message}`);
}
for (const id of accounts) {
const profile = await db.from("profiles").delete().eq("id", id);
if (profile.error) throw profile.error;
const account = await db.auth.admin.deleteUser(id);
if (account.error) throw account.error;
}
}

View File

@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { randomUUID, randomBytes } from "node:crypto";
import { existsSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { createClient } from "@supabase/supabase-js";
if (existsSync(".env.local")) process.loadEnvFile(".env.local");
const origin = new URL(process.argv[2] || "http://127.0.0.1:3007").origin;
@@ -9,9 +10,20 @@ const db = createClient(
process.env.SUPABASE_SECRET_KEY,
{ auth: { persistSession: false, autoRefreshToken: false } },
);
const rooms = [];
const rooms = [],
accounts = [];
await mkdir(".data/multiplayer-check", { recursive: true });
const journal = () =>
writeFile(
".data/multiplayer-check/http-fixtures.json",
JSON.stringify({ rooms, accounts }),
);
const checked = (result) => {
if (result.error) throw new Error(result.error.message);
return result.data;
};
function player() {
let cookie;
const cookies = new Map();
return async (path, body, expected = 200) => {
const response = await fetch(`${origin}/api/v1/${path}`, {
method: body === undefined ? "GET" : "POST",
@@ -19,35 +31,88 @@ function player() {
...(body === undefined
? {}
: { origin, "content-type": "application/json" }),
...(cookie ? { cookie } : {}),
cookie: [...cookies]
.map(([key, value]) => `${key}=${value}`)
.join("; "),
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(30000),
});
if (response.headers.get("set-cookie"))
cookie = response.headers.get("set-cookie").split(";")[0];
for (const cookie of response.headers.getSetCookie()) {
const part = cookie.split(";")[0],
at = part.indexOf("=");
cookies.set(part.slice(0, at), part.slice(at + 1));
}
const data = await response.json();
assert.equal(response.status, expected, JSON.stringify({ path, data }));
assert.match(response.headers.get("cache-control"), /no-store/);
return data;
};
}
async function account(displayName) {
const email = `multiplayer-check-${randomUUID()}@example.com`,
password = randomBytes(24).toString("base64url");
const { user } = checked(
await db.auth.admin.createUser({
email,
password,
email_confirm: true,
user_metadata: { display_name: "Stale metadata name" },
}),
);
accounts.push(user.id);
await journal();
checked(
await db
.from("profiles")
.insert({ id: user.id, display_name: displayName }),
);
const api = player();
await api("auth", { action: "login", email, password });
return api;
}
try {
const host = player(),
const host = await account("HTTP Host"),
friend = player(),
outsider = player();
const catalog = await host("catalog");
const datasetId = catalog.maps[0]?.id;
assert.ok(datasetId, "A published map is required");
await outsider(
"rooms",
{ datasetId, difficulty: "normal", playerLimit: 2 },
401,
);
await host(
"rooms",
{
datasetId,
difficulty: "hard",
playerLimit: 2,
displayName: "Forged host",
},
400,
);
const created = await host("rooms", {
datasetId,
difficulty: "hard",
displayName: "HTTP Host",
playerLimit: 2,
});
rooms.push(created.id);
await journal();
const path = `rooms/${created.id}`;
const hostView = await host(path);
assert.equal(
hostView.players.find((p) => p.id === hostView.hostId).name,
"HTTP Host",
"host uses stored profile, not stale auth metadata",
);
const rejoined = await host(`${path}/join`, { displayName: "Forged host" });
assert.equal(
rejoined.players.find((p) => p.id === rejoined.hostId).name,
"HTTP Host",
"host rejoin cannot rename the account",
);
const preview = await outsider(path);
assert.equal(preview.you, null);
assert.deepEqual(preview.rounds, []);
@@ -187,14 +252,14 @@ try {
403,
);
assert.equal(results.rounds.flatMap((round) => round.results).length, 10);
const capacityHost = player();
const capacityHost = await account("Capacity Host");
const full = await capacityHost("rooms", {
datasetId,
difficulty: "normal",
displayName: "Capacity Host",
playerLimit: 16,
});
rooms.push(full.id);
await journal();
await Promise.all(
Array.from({ length: 14 }, (_, i) =>
player()(`rooms/${full.id}/join`, { displayName: `Capacity ${i + 2}` }),
@@ -212,6 +277,11 @@ try {
const rejection = attempts.find((result) => result.status === "rejected");
assert.match(rejection.reason.message, /This room is full/);
assert.equal((await capacityHost(`rooms/${full.id}`)).playerCount, 16);
assert.equal(
(await capacityHost(`rooms/${full.id}/leave`, {})).status,
"closed",
"guests cannot become party hosts",
);
console.log(
"Multiplayer HTTP smoke passed: private room chat, trusted sender names, validation, concurrent-send throttling, retries, revoked membership, five shared rounds, signed media, and simultaneous 16-player capacity checks.",
);
@@ -223,5 +293,13 @@ try {
.in("id", rooms);
if (error)
throw new Error(`Multiplayer test-room cleanup failed: ${error.message}`);
rooms.length = 0;
await journal();
}
for (const id of accounts) {
checked(await db.from("profiles").delete().eq("id", id));
checked(await db.auth.admin.deleteUser(id));
}
accounts.length = 0;
await journal();
}

View File

@@ -65,6 +65,107 @@ try {
])
).rows[0].room;
}
await db.query(
"insert into public.profiles(id,display_name) values($1,'Account host')",
[account],
);
const secondAccount = (
await db.query(
"insert into auth.users values(gen_random_uuid()) returning id",
)
).rows[0].id;
await db.query(
"insert into public.profiles(id,display_name) values($1,'Account friend')",
[secondAccount],
);
const accountRoom = async () =>
(
await db.query(
"select public.create_map_room($1,'normal','Forged host',4,$2,$3) as id",
[dataset, secret(1), account],
)
).rows[0].id;
await assert.rejects(
db.query(
"select public.create_map_room($1,'normal','Guest host',4,$2,null)",
[dataset, secret(4)],
),
/Log in to create/,
);
const identityRoom = await accountRoom();
const identityHost = (await act(identityRoom, 1)).players[0];
assert.equal(
identityHost.name,
"Account host",
"SQL ignores a forged host nickname",
);
assert.equal(
(
await db.query(
"select account_id from public.multiplayer_players where id=$1",
[identityHost.id],
)
).rows[0].account_id,
account,
"host linked atomically",
);
await act(identityRoom, 2, "join", { name: "Guest first" });
await db.query(
"select public.join_multiplayer_room($1,$2,$3,'Forged friend')",
[identityRoom, secret(3), secondAccount],
);
let identityView = await act(identityRoom, 3);
assert.equal(
identityView.players.find((p) => p.id === identityView.you).name,
"Account friend",
"signed-in join ignores a forged nickname",
);
await act(identityRoom, 1, "leave");
identityView = await act(identityRoom, 3);
assert.equal(
identityView.hostId,
identityView.you,
"host transfer skips a guest for an account",
);
await act(identityRoom, 3, "leave");
assert.equal(
(await act(identityRoom, 2)).status,
"closed",
"guest-only party closes when host leaves",
);
const guestOnly = await accountRoom();
await db.query(
"select public.join_multiplayer_room($1,$2,null,'Guest custom nickname')",
[guestOnly, secret(2)],
);
const guestView = await act(guestOnly, 2);
assert.equal(
guestView.players.find((p) => p.id === guestView.you)?.name,
"Guest custom nickname",
);
const cleanupEvent = (
await db.query("select public.publish_discord_party($1,$2) as id", [
guestOnly,
secret(1),
])
).rows[0].id;
await db.query(
"update public.discord_announcements set state='sent',channel_id='123456789012345678',message_id='123456789012345679' where id=$1",
[cleanupEvent],
);
await act(guestOnly, 1, "leave");
assert.ok(
(
await db.query(
"select closed_at from public.discord_announcements where id=$1",
[cleanupEvent],
)
).rows[0].closed_at,
"guest-only host departure also queues Discord cleanup",
);
console.log(
"Multiplayer SQL account identity: forged names rejected, guest joins retained, authenticated transfer and guest-only closure verified.",
);
for (const cap of [1, 17, null])
await assert.rejects(create(cap), /Choose between 2 and 16 players/);
const room = await create();
@@ -255,6 +356,15 @@ try {
await assert.rejects(chat(capacity, 16), /Chat permission/);
assert.equal((await act(capacity, 16)).you, null);
await act(capacity, 17, "join", { name: "Replacement" });
const nextHost = (await act(capacity, 2)).you;
await db.query(
"insert into public.profiles(id,display_name) values($1,'Next host') on conflict(id) do nothing",
[account],
);
await db.query(
"update public.multiplayer_players set account_id=$1 where id=$2",
[account, nextHost],
);
await act(capacity, 1, "leave");
await assert.rejects(
chat(capacity, 1, "Left", randomUUID()),

View File

@@ -231,7 +231,6 @@ try {
await api(player.context, "rooms", {
datasetId: map.id,
difficulty: "easy",
displayName: "Temporary host alias",
playerLimit: 3,
})
).id;

View File

@@ -41,7 +41,15 @@ export type BugReportRecord = {
title: string;
description: string;
page_path: string;
state: "draft" | "pending" | "open";
state: "draft" | "pending" | "open" | "done";
closed_at?: string | null;
delete_after?: string | null;
linear_done_synced?: boolean;
cleanup_run_id?: string | null;
cleanup_lease_until?: string | null;
cleanup_error?: string | null;
purge_started_at?: string | null;
status_checked_at?: string | null;
screenshot_expected: boolean;
screenshot_path: string | null;
linear_asset_url: string | null;
@@ -76,6 +84,10 @@ export type BugReportView = Pick<
| "linear_identifier"
| "glow_requested"
| "created_at"
| "closed_at"
| "delete_after"
| "linear_done_synced"
| "cleanup_error"
> & {
screenshot: string | null;
isGuest: boolean;
@@ -88,8 +100,10 @@ export type BugReportView = Pick<
};
export type SupportCommand =
| { kind: "reply"; text: string }
| { kind: "glow"; enabled: boolean };
| { kind: "glow"; enabled: boolean }
| { kind: "close" };
export function supportCommand(body: string): SupportCommand | null {
if (body.trim() === "#player-report-close") return { kind: "close" };
const reply = /^#player-report-send\s+([\s\S]+)$/.exec(body.trim());
if (reply) {
const parsed = text(1, 6000).safeParse(reply[1]);

View File

@@ -702,6 +702,45 @@ export type Database = {
[_ in never]: never;
};
Functions: {
receive_bug_close: {
Args: { p_report: string; p_external: string };
Returns: boolean;
};
set_bug_report_done: {
Args: {
p_report: string;
p_done: boolean;
p_closed_at?: string | null;
};
Returns: boolean;
};
claim_bug_cleanup: {
Args: { p_report: string; p_worker: string };
Returns: import("./bug-reports").BugReportRecord[];
SetofOptions: {
from: "*";
to: "bug_reports";
isOneToOne: false;
isSetofReturn: true;
};
};
begin_bug_purge: {
Args: { p_report: string; p_closed_at: string };
Returns: boolean;
};
finish_bug_purge: {
Args: { p_report: string; p_closed_at: string };
Returns: boolean;
};
join_multiplayer_room: {
Args: {
p_room: string;
p_access_hash: string;
p_owner: string | null;
p_name: string;
};
Returns: Json;
};
claim_discord_cleanup: {
Args: { p_worker: string; p_target?: string | null };
Returns: import("./discord").DiscordAnnouncement[];

View File

@@ -830,5 +830,16 @@
"Discord rate limit. Delivery will retry.": "Discord ограничил частоту запросов. Отправка будет повторена.",
"Discord rejected this notification.": "Discord отклонил это объявление.",
"Discord did not confirm delivery.": "Discord не подтвердил отправку.",
"Discord delivery could not be confirmed. Delivery will retry.": "Не удалось подтвердить отправку в Discord. Она будет повторена."
"Discord delivery could not be confirmed. Delivery will retry.": "Не удалось подтвердить отправку в Discord. Она будет повторена.",
"Host display name": "Отображаемое имя ведущего",
"Log in to create a multiplayer room": "Войдите в аккаунт, чтобы создать комнату",
"Done": "Завершено",
"Close report": "Закрыть обращение",
"Report closed.": "Обращение закрыто.",
"This bug report is Done": "Это обращение уже завершено",
"Wait for support to receive this report before closing it": "Дождитесь получения обращения поддержкой, прежде чем закрыть его",
"This report is closed and will be deleted from MineGeo and Linear after three days.": "Обращение закрыто и будет удалено из MineGeo и Linear через три дня.",
"Scheduled deletion": "Дата удаления",
"Syncing Done status with Linear…": "Синхронизируем статус «Завершено» с Linear…",
"Support cleanup paused. The support team may need to update Linear permissions.": "Удаление обращения приостановлено. Поддержке может потребоваться обновить права доступа к Linear."
}

View File

@@ -2,12 +2,13 @@ import { z } from "zod";
import { displayNameSchema } from "./account";
import type { Bounds, Difficulty } from "./protocol";
export const createRoomSchema = z.object({
datasetId: z.string().uuid(),
difficulty: z.enum(["easy", "normal", "hard"]),
displayName: displayNameSchema,
playerLimit: z.number().int().min(2).max(16),
});
export const createRoomSchema = z
.object({
datasetId: z.string().uuid(),
difficulty: z.enum(["easy", "normal", "hard"]),
playerLimit: z.number().int().min(2).max(16),
})
.strict();
export const joinRoomSchema = z.object({ displayName: displayNameSchema });
export const chatMessageSchema = z.object({
clientId: z.string().uuid(),

View File

@@ -0,0 +1,80 @@
alter table public.bug_reports drop constraint bug_reports_state_check;
alter table public.bug_reports add constraint bug_reports_state_check check(state in ('draft','pending','open','done'));
alter table public.bug_reports
add column closed_at timestamptz,
add column delete_after timestamptz,
add column linear_done_synced boolean not null default false,
add column cleanup_run_id text,
add column cleanup_lease_until timestamptz,
add column cleanup_error text,
add column purge_started_at timestamptz,
add column status_checked_at timestamptz;
create index bug_reports_cleanup on public.bug_reports(delete_after) where state='done';
create function public.set_bug_report_done(p_report uuid,p_done boolean,p_closed_at timestamptz default null)
returns boolean language plpgsql security invoker set search_path='' as $$
declare report public.bug_reports; finished timestamptz;
begin
select * into report from public.bug_reports where id=p_report and state<>'draft' for update;
if not found or report.purge_started_at is not null then return false; end if;
if p_done then
finished=coalesce(report.closed_at,least(coalesce(p_closed_at,clock_timestamp()),clock_timestamp()));
update public.bug_reports set state='done',closed_at=finished,delete_after=finished+interval '3 days',updated_at=clock_timestamp()
where id=p_report;
elsif report.state='done' then
update public.bug_reports set state='open',closed_at=null,delete_after=null,linear_done_synced=false,
cleanup_run_id=null,cleanup_lease_until=null,cleanup_error=null,updated_at=clock_timestamp() where id=p_report;
end if;
return true;
end; $$;
create function public.claim_bug_cleanup(p_report uuid,p_worker text)
returns setof public.bug_reports language sql security invoker set search_path='' as $$
update public.bug_reports set cleanup_run_id=p_worker,cleanup_lease_until=clock_timestamp()+interval '3 minutes'
where id=p_report and state='done' and closed_at is not null
and (cleanup_run_id is null or cleanup_run_id like 'starting:%')
and (cleanup_lease_until is null or cleanup_lease_until<clock_timestamp()) returning *;
$$;
create function public.begin_bug_purge(p_report uuid,p_closed_at timestamptz)
returns boolean language plpgsql security invoker set search_path='' as $$
begin
update public.bug_reports set purge_started_at=coalesce(purge_started_at,clock_timestamp())
where id=p_report and state='done' and closed_at=p_closed_at and delete_after<=clock_timestamp() and linear_done_synced;
return found;
end; $$;
create function public.finish_bug_purge(p_report uuid,p_closed_at timestamptz)
returns boolean language plpgsql security invoker set search_path='' as $$
begin
delete from public.bug_reports where id=p_report and state='done' and closed_at=p_closed_at
and delete_after<=clock_timestamp() and purge_started_at is not null;
return found;
end; $$;
-- A closed conversation cannot gain new messages while deletion is in progress.
create function public.guard_done_bug_message() returns trigger
language plpgsql security invoker set search_path='' as $$
begin
perform 1 from public.bug_reports where id=new.report_id and purge_started_at is null
and (new.kind<>'user' or state<>'done') for share;
if not found then raise exception 'This bug report is Done'; end if;
return new;
end; $$;
create trigger guard_done_bug_message before insert on public.bug_report_messages
for each row execute function public.guard_done_bug_message();
revoke all on function public.set_bug_report_done(uuid,boolean,timestamptz),public.claim_bug_cleanup(uuid,text),
public.begin_bug_purge(uuid,timestamptz),public.finish_bug_purge(uuid,timestamptz),public.guard_done_bug_message()
from public,anon,authenticated;
grant execute on function public.set_bug_report_done(uuid,boolean,timestamptz),public.claim_bug_cleanup(uuid,text),
public.begin_bug_purge(uuid,timestamptz),public.finish_bug_purge(uuid,timestamptz),public.guard_done_bug_message() to service_role;
create function public.receive_bug_close(p_report uuid,p_external uuid) returns boolean
language plpgsql security invoker set search_path='' as $$
begin
perform 1 from public.bug_reports where id=p_report and state<>'draft' and purge_started_at is null for update;
if not found or exists(select 1 from public.bug_report_messages where external_id=p_external) then return false; end if;
insert into public.bug_report_messages(report_id,kind,body,author_name,delivered,external_id)
values(p_report,'system','Report closed.','Support',true,p_external);
return public.set_bug_report_done(p_report,true,null);
end; $$;
revoke all on function public.receive_bug_close(uuid,uuid) from public,anon,authenticated;
grant execute on function public.receive_bug_close(uuid,uuid) to service_role;

View File

@@ -0,0 +1,65 @@
-- Hosting always belongs to an account. The browser cannot choose the host name.
create or replace function public.create_map_room(p_dataset uuid,p_difficulty text,p_name text,p_limit integer,p_access_hash text,p_owner uuid)
returns uuid language plpgsql security invoker set search_path='' as $$
declare rid uuid; revision uuid; pid uuid:=gen_random_uuid(); rev public.revisions; vp jsonb; n integer:=0; account_name text;
begin
select display_name into account_name from public.profiles where id=p_owner;
if not found then raise exception 'Log in to create a multiplayer room'; end if;
if p_limit is null or p_limit not between 2 and 16 then raise exception 'Choose between 2 and 16 players'; end if;
if p_difficulty is null or p_difficulty not in ('easy','normal','hard') then raise exception 'Invalid difficulty'; end if;
revision:=public.playable_map_revision(p_dataset,p_owner);
select * into rev from public.revisions where id=revision;
if not found then raise exception 'Map unavailable'; end if;
if coalesce(jsonb_array_length(rev.manifest->'viewpoints'),0)<5 then raise exception 'Map has fewer than five viewpoints'; end if;
insert into public.multiplayer_rooms(revision_id,host_player_id,difficulty,player_limit) values(rev.id,pid,p_difficulty,p_limit) returning id into rid;
insert into public.multiplayer_players(id,room_id,access_hash,display_name,account_id) values(pid,rid,p_access_hash,account_name,p_owner);
for vp in select value from jsonb_array_elements(rev.manifest->'viewpoints') order by random() limit 5 loop
insert into public.multiplayer_rounds(room_id,round_number,viewpoint) values(rid,n,vp); n:=n+1;
end loop;
return rid;
end; $$;
-- Link signed-in joins within the room lock, before a concurrent host can leave.
create function public.join_multiplayer_room(p_room uuid,p_access_hash text,p_owner uuid,p_name text)
returns jsonb language plpgsql security invoker set search_path='' as $$
declare account_name text; result jsonb;
begin
perform 1 from public.multiplayer_rooms where id=p_room for update;
if p_owner is not null then
select display_name into account_name from public.profiles where id=p_owner;
if not found then raise exception 'Account not found'; end if;
else account_name:=p_name;
end if;
result:=public.multiplayer_action(p_room,p_access_hash,'join',jsonb_build_object('name',account_name));
if p_owner is not null then
update public.multiplayer_players set account_id=p_owner
where room_id=p_room and access_hash=p_access_hash and account_id is null and not has_left;
end if;
return result;
end; $$;
-- A departing host can hand the party to another signed-in player, never a guest.
create function public.ensure_account_party_host() returns trigger
language plpgsql security invoker set search_path='' as $$
declare replacement uuid;
begin
if new.host_player_id is not distinct from old.host_player_id or new.status='closed' then return new; end if;
if exists(select 1 from public.multiplayer_players p join public.profiles a on a.id=p.account_id
where p.id=new.host_player_id and p.room_id=new.id and not p.has_left) then return new; end if;
select p.id into replacement from public.multiplayer_players p join public.profiles a on a.id=p.account_id
where p.room_id=new.id and not p.has_left order by p.joined_at,p.id limit 1;
if replacement is null then
new.status:='closed'; new.transition_at:=null;
else new.host_player_id:=replacement;
end if;
return new;
end; $$;
create trigger ensure_account_party_host before update of host_player_id on public.multiplayer_rooms
for each row execute function public.ensure_account_party_host();
-- Capture a close even when its status was set by the host-transfer trigger.
create trigger close_transferred_party_discord after update of host_player_id on public.multiplayer_rooms
for each row when (new.status='closed') execute function public.close_party_discord();
revoke all on function public.join_multiplayer_room(uuid,text,uuid,text),public.ensure_account_party_host() from public,anon,authenticated;
grant execute on function public.join_multiplayer_room(uuid,text,uuid,text),public.ensure_account_party_host() to service_role;

View File

@@ -44,6 +44,8 @@ test("reports exclude arbitrary URLs, secrets in page queries, malformed message
);
});
test("only exact staff prefixes publish replies or explicitly change glow", () => {
assert.deepEqual(supportCommand("#player-report-close"), { kind: "close" });
assert.equal(supportCommand("#player-report-close everyone"), null);
assert.deepEqual(supportCommand("#player-report-send Dear User\nThanks!"), {
kind: "reply",
text: "Dear User\nThanks!",

195
tests/bug-retention.test.ts Normal file
View File

@@ -0,0 +1,195 @@
import assert from "node:assert/strict";
import test from "node:test";
import { purgeClosedBugReport } from "../lib/bug-retention";
import { LinearSupportClient } from "../lib/linear-support-client";
import type { BugReportRecord } from "../shared/bug-reports";
const id = "00000000-0000-4000-8000-000000000001",
team = "00000000-0000-4000-8000-000000000002";
const closedAt = new Date(1000).toISOString(),
due = 1000 + 3 * 86400000;
const report = {
id,
state: "done",
closed_at: closedAt,
delete_after: new Date(due).toISOString(),
linear_done_synced: true,
} as BugReportRecord;
test("report retention never purges early, open reports, or an older closure generation", async () => {
for (const [record, now] of [
[report, due - 1],
[{ ...report, state: "open" }, due],
[{ ...report, closed_at: new Date(2000).toISOString() }, due],
[null, due],
] as const) {
await purgeClosedBugReport(id, closedAt, {
record: async () => record as BugReportRecord | null,
now: () => now,
linear: {
issue: async () => {
throw new Error("Must not query Linear");
},
remove: async () => assert.fail(),
},
begin: async () => {
assert.fail();
},
reopen: async () => assert.fail(),
removeFiles: async () => assert.fail(),
finish: async () => assert.fail(),
});
}
});
test("reopening a Linear issue cancels local cleanup and completed reports purge in a retry-safe order", async () => {
for (const state of ["started", "completed"]) {
const actions: string[] = [];
await purgeClosedBugReport(id, closedAt, {
record: async () => report,
now: () => due,
linear: {
issue: async () => ({
id,
identifier: "TEST-1",
team: { id: team },
state: { id: team, name: state, type: state },
}),
remove: async () => {
actions.push("linear");
},
},
begin: async () => {
actions.push("lock");
return true;
},
reopen: async () => {
actions.push("reopen");
},
removeFiles: async (paths) => {
assert.deepEqual(paths, [`${id}/input`, `${id}/screenshot.webp`]);
actions.push("storage");
},
finish: async () => {
actions.push("database");
},
});
assert.deepEqual(
actions,
state === "started"
? ["reopen"]
: ["lock", "linear", "storage", "database"],
);
}
});
test("failed external deletion preserves the local retry record", async () => {
for (const failure of ["linear", "storage"]) {
let finished = false;
await assert.rejects(
purgeClosedBugReport(id, closedAt, {
record: async () => ({ ...report, purge_started_at: closedAt }),
now: () => due,
linear: {
issue: async () => assert.fail(),
remove: async () => {
if (failure === "linear") throw new Error("retry");
},
},
begin: async () => true,
reopen: async () => assert.fail(),
removeFiles: async () => {
if (failure === "storage") throw new Error("retry");
},
finish: async () => {
finished = true;
},
}),
/retry/,
);
assert.equal(finished, false);
}
});
test("Linear Done uses the report team's Done state, validates acknowledgment and deletes idempotently", async () => {
let current = "started",
deleted = false;
const calls: string[] = [];
const client = new LinearSupportClient(
"private",
team,
undefined,
async (_url, init) => {
const { query, variables } = JSON.parse(String(init?.body));
calls.push(query.split("(")[0]);
if (query.startsWith("query SupportIssue"))
return Response.json({
data: {
issue: deleted
? null
: {
id,
identifier: "TEST-1",
team: { id: team },
state: {
id: current,
name: current === "done" ? "Done" : "In Progress",
type: current === "done" ? "completed" : "started",
},
},
},
});
if (query.startsWith("query SupportDoneState")) {
assert.equal(variables.team, team);
return Response.json({
data: {
team: {
states: {
nodes: [{ id: "done", name: "Done", type: "completed" }],
},
},
},
});
}
if (query.startsWith("mutation SupportDone")) {
assert.equal(variables.input.stateId, "done");
current = "done";
return Response.json({
data: {
issueUpdate: {
success: true,
issue: { id, state: { id: "done" } },
},
},
});
}
if (query.startsWith("mutation SupportDelete")) {
deleted = true;
return Response.json({ data: { issueDelete: { success: true } } });
}
throw new Error("Unexpected request");
},
);
await client.done(id);
await client.done(id);
await client.remove(id);
await client.remove(id);
assert.equal(calls.filter((c) => c === "mutation SupportDone").length, 1);
assert.equal(calls.filter((c) => c === "mutation SupportDelete").length, 1);
const wrongTeam = new LinearSupportClient(
"secret",
team,
undefined,
async () =>
Response.json({ data: { issue: { id, team: { id: "other" } } } }),
);
await assert.rejects(wrongTeam.remove(id), /team mismatch/);
const failed = new LinearSupportClient(
"secret",
team,
undefined,
async (_url, init) =>
Response.json({
data: JSON.parse(String(init?.body)).query.startsWith("query")
? { issue: { id, team: { id: team } } }
: { issueDelete: { success: false } },
}),
);
await assert.rejects(failed.remove(id), /not confirmed/);
});

View File

@@ -49,22 +49,33 @@ test("room limits include the owner and stay between 2 and 16", () => {
createRoomSchema.parse({
datasetId: map,
difficulty: "normal",
displayName: " Alex ",
playerLimit,
}).displayName,
"Alex",
}).playerLimit,
playerLimit,
);
for (const playerLimit of [1, 17, 2.5, "16", null])
assert.equal(
createRoomSchema.safeParse({
datasetId: map,
difficulty: "normal",
displayName: "Alex",
playerLimit,
}).success,
false,
);
});
test("party creation rejects a client-selected host identity", () => {
const room = { datasetId: map, difficulty: "normal", playerLimit: 4 };
assert.equal(createRoomSchema.safeParse(room).success, true);
for (const identity of [
{ displayName: "Impostor" },
{ accountId: map },
{ name: "Impostor" },
])
assert.equal(
createRoomSchema.safeParse({ ...room, ...identity }).success,
false,
);
});
test("multiplayer names and guesses reject hidden controls, invalid rounds, and non-finite positions", () => {
assert.equal(
joinRoomSchema.parse({ displayName: " Друг " }).displayName,