Add account based multiplayer hosting and bug report retention
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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…")
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
146
lib/bug-cleanup-workflow.ts
Normal 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
98
lib/bug-lifecycle.ts
Normal 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 }),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
38
lib/bug-retention.ts
Normal 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();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
|
||||
146
scripts/test-bug-retention-http.mjs
Normal file
146
scripts/test-bug-retention-http.mjs
Normal 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",
|
||||
}),
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -231,7 +231,6 @@ try {
|
||||
await api(player.context, "rooms", {
|
||||
datasetId: map.id,
|
||||
difficulty: "easy",
|
||||
displayName: "Temporary host alias",
|
||||
playerLimit: 3,
|
||||
})
|
||||
).id;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
80
supabase/migrations/20261010053310_bug_report_retention.sql
Normal file
80
supabase/migrations/20261010053310_bug_report_retention.sql
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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
195
tests/bug-retention.test.ts
Normal 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/);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user