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 { admin, required } from "@/lib/supabase";
|
||||||
import { verifySupportWebhook } from "@/lib/support-signature";
|
import { verifySupportWebhook } from "@/lib/support-signature";
|
||||||
import { supportCommand } from "@/shared/bug-reports";
|
import { supportCommand } from "@/shared/bug-reports";
|
||||||
|
import { reconcileBugStatus, scheduleBugCleanup } from "@/lib/bug-lifecycle";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -59,6 +60,17 @@ export async function POST(request: NextRequest) {
|
|||||||
.safeParse(decoded);
|
.safeParse(decoded);
|
||||||
if (!payload.success) return new NextResponse(null, { status: 400 });
|
if (!payload.success) return new NextResponse(null, { status: 400 });
|
||||||
const event = payload.data;
|
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.
|
// Only explicit new staff comments are public. Internal comments and user echoes stay private.
|
||||||
if (
|
if (
|
||||||
event.action !== "create" ||
|
event.action !== "create" ||
|
||||||
@@ -71,6 +83,16 @@ export async function POST(request: NextRequest) {
|
|||||||
const command = supportCommand(event.data.body ?? "");
|
const command = supportCommand(event.data.body ?? "");
|
||||||
if (!command) return NextResponse.json({ ok: true });
|
if (!command) return NextResponse.json({ ok: true });
|
||||||
try {
|
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(
|
required(
|
||||||
await admin().rpc("receive_bug_reply", {
|
await admin().rpc("receive_bug_reply", {
|
||||||
p_report: event.data.issueId,
|
p_report: event.data.issueId,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { getI18n } from "@/lib/i18n";
|
|||||||
import { Header, Footer } from "@/components/Shell";
|
import { Header, Footer } from "@/components/Shell";
|
||||||
import { DraftEditor } from "@/components/DraftEditor";
|
import { DraftEditor } from "@/components/DraftEditor";
|
||||||
import { user } from "@/lib/supabase";
|
import { user } from "@/lib/supabase";
|
||||||
|
import { accountProfile } from "@/lib/profile";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
export default async function Draft({
|
export default async function Draft({
|
||||||
params,
|
params,
|
||||||
@@ -10,15 +11,17 @@ export default async function Draft({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = await getI18n();
|
const { t } = await getI18n();
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
if (!(await user()))
|
const account = await user();
|
||||||
|
if (!account)
|
||||||
redirect(`/log-in?next=${encodeURIComponent("/dashboard/" + id)}`);
|
redirect(`/log-in?next=${encodeURIComponent("/dashboard/" + id)}`);
|
||||||
|
const profile = await accountProfile(account);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<main className="section page-content">
|
<main className="section page-content">
|
||||||
<span className="eyebrow">{t("YOUR WORLD · YOUR LOCATIONS")}</span>
|
<span className="eyebrow">{t("YOUR WORLD · YOUR LOCATIONS")}</span>
|
||||||
<h1>{t("Map workshop")}</h1>
|
<h1>{t("Map workshop")}</h1>
|
||||||
<DraftEditor id={id} />
|
<DraftEditor id={id} hostName={profile.display_name} />
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { getMap } from "@/lib/catalog";
|
|||||||
import { ContributionInstructions } from "@/components/ContributionInstructions";
|
import { ContributionInstructions } from "@/components/ContributionInstructions";
|
||||||
import { ReportContent } from "@/components/ReportContent";
|
import { ReportContent } from "@/components/ReportContent";
|
||||||
import { user } from "@/lib/supabase";
|
import { user } from "@/lib/supabase";
|
||||||
|
import { accountProfile } from "@/lib/profile";
|
||||||
import { ExportMap } from "@/components/MapTransfer";
|
import { ExportMap } from "@/components/MapTransfer";
|
||||||
export default async function ServerPage({
|
export default async function ServerPage({
|
||||||
params,
|
params,
|
||||||
@@ -19,6 +20,7 @@ export default async function ServerPage({
|
|||||||
const map = await getMap(id);
|
const map = await getMap(id);
|
||||||
if (!map) notFound();
|
if (!map) notFound();
|
||||||
const account = await user();
|
const account = await user();
|
||||||
|
const profile = account ? await accountProfile(account) : null;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
@@ -63,7 +65,7 @@ export default async function ServerPage({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StartGame datasetId={id} />
|
<StartGame datasetId={id} hostName={profile?.display_name ?? null} />
|
||||||
</div>
|
</div>
|
||||||
{map.allow_contributions && (
|
{map.allow_contributions && (
|
||||||
<ContributionInstructions
|
<ContributionInstructions
|
||||||
|
|||||||
@@ -36,7 +36,13 @@ type Draft = {
|
|||||||
banned_at: string | null;
|
banned_at: string | null;
|
||||||
ban_reason: 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 { t } = useI18n();
|
||||||
const dirty = useRef(false);
|
const dirty = useRef(false);
|
||||||
const [draft, setDraft] = useState<Draft | null>(null),
|
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.",
|
"This shared map is private and playable without moderator review. Only the original owner can submit it for public review.",
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
{draft.state === "draft" && <StartGame datasetId={id} />}
|
{draft.state === "draft" && (
|
||||||
|
<StartGame datasetId={id} hostName={hostName} />
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{!draft.parent_dataset_id &&
|
{!draft.parent_dataset_id &&
|
||||||
|
|||||||
@@ -2,16 +2,22 @@
|
|||||||
import { useI18n } from "@/components/I18nProvider";
|
import { useI18n } from "@/components/I18nProvider";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { ArrowRight, Timer, ZoomIn, Users } from "lucide-react";
|
import { ArrowRight, Timer, ZoomIn, Users } from "lucide-react";
|
||||||
import { difficulties, type Difficulty } from "@/shared/protocol";
|
import { difficulties, type Difficulty } from "@/shared/protocol";
|
||||||
import { api } from "@/lib/client-api";
|
import { api } from "@/lib/client-api";
|
||||||
export { 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 { t } = useI18n();
|
||||||
const [difficulty, setDifficulty] = useState<Difficulty>("normal"),
|
const [difficulty, setDifficulty] = useState<Difficulty>("normal"),
|
||||||
[mode, setMode] = useState<"solo" | "multiplayer">("solo"),
|
[mode, setMode] = useState<"solo" | "multiplayer">("solo"),
|
||||||
[displayName, setDisplayName] = useState(""),
|
|
||||||
[playerLimit, setPlayerLimit] = useState(4),
|
[playerLimit, setPlayerLimit] = useState(4),
|
||||||
[loading, setLoading] = useState(false),
|
[loading, setLoading] = useState(false),
|
||||||
[error, setError] = useState("");
|
[error, setError] = useState("");
|
||||||
@@ -25,7 +31,7 @@ export function StartGame({ datasetId }: { datasetId: string }) {
|
|||||||
{
|
{
|
||||||
datasetId,
|
datasetId,
|
||||||
difficulty,
|
difficulty,
|
||||||
...(mode === "multiplayer" ? { displayName, playerLimit } : {}),
|
...(mode === "multiplayer" ? { playerLimit } : {}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
router.push((mode === "solo" ? "/play/" : "/multiplayer/") + session.id);
|
router.push((mode === "solo" ? "/play/" : "/multiplayer/") + session.id);
|
||||||
@@ -82,17 +88,16 @@ export function StartGame({ datasetId }: { datasetId: string }) {
|
|||||||
</p>
|
</p>
|
||||||
{mode === "multiplayer" && (
|
{mode === "multiplayer" && (
|
||||||
<div className="multiplayer-settings">
|
<div className="multiplayer-settings">
|
||||||
<label htmlFor="room-display-name">{t("Your player name")}</label>
|
{hostName ? (
|
||||||
<input
|
<p>
|
||||||
id="room-display-name"
|
{t("Host display name")}: <strong>{hostName}</strong>
|
||||||
value={displayName}
|
</p>
|
||||||
onChange={(event) => setDisplayName(event.target.value)}
|
) : (
|
||||||
minLength={2}
|
<p className="muted">
|
||||||
maxLength={32}
|
{t("Log in to create a multiplayer room")}{" "}
|
||||||
placeholder={t("Enter a player name")}
|
<Link href="/log-in?next=/servers">{t("Log in")}</Link>
|
||||||
autoComplete="nickname"
|
</p>
|
||||||
disabled={loading}
|
)}
|
||||||
/>
|
|
||||||
<label htmlFor="room-player-limit">
|
<label htmlFor="room-player-limit">
|
||||||
{t("Player limit (including you)")}
|
{t("Player limit (including you)")}
|
||||||
</label>
|
</label>
|
||||||
@@ -118,9 +123,7 @@ export function StartGame({ datasetId }: { datasetId: string }) {
|
|||||||
<button
|
<button
|
||||||
className="button primary full"
|
className="button primary full"
|
||||||
onClick={start}
|
onClick={start}
|
||||||
disabled={
|
disabled={loading || (mode === "multiplayer" && !hostName)}
|
||||||
loading || (mode === "multiplayer" && displayName.trim().length < 2)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{loading
|
{loading
|
||||||
? t("Preparing your world…")
|
? t("Preparing your world…")
|
||||||
|
|||||||
@@ -218,7 +218,10 @@ export function SupportInbox({ pagePath }: { pagePath: string }) {
|
|||||||
{inbox.reports.map((report) => (
|
{inbox.reports.map((report) => (
|
||||||
<li key={report.id}>
|
<li key={report.id}>
|
||||||
<Link href={`/support/${report.id}`}>
|
<Link href={`/support/${report.id}`}>
|
||||||
<strong>{report.title}</strong>
|
<strong>
|
||||||
|
{report.title}
|
||||||
|
{report.state === "done" && <> · {t("Done")}</>}
|
||||||
|
</strong>
|
||||||
<small>
|
<small>
|
||||||
{formatDate(report.created_at)} ·{" "}
|
{formatDate(report.created_at)} ·{" "}
|
||||||
{report.linear_identifier ??
|
{report.linear_identifier ??
|
||||||
@@ -331,6 +334,47 @@ export function SupportConversation({ id }: { id: string }) {
|
|||||||
{report.linear_identifier ?? t("Waiting for support")}
|
{report.linear_identifier ?? t("Waiting for support")}
|
||||||
</span>
|
</span>
|
||||||
<h1>{report.title}</h1>
|
<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" && (
|
{report.state === "pending" && (
|
||||||
<p role="status">
|
<p role="status">
|
||||||
{t("Your report is saved. We’re connecting it to support.")}
|
{t("Your report is saved. We’re connecting it to support.")}
|
||||||
@@ -432,7 +476,7 @@ export function SupportConversation({ id }: { id: string }) {
|
|||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{report.state !== "draft" && (
|
{report.state !== "draft" && report.state !== "done" && (
|
||||||
<form
|
<form
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
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.
|
`#user-report-message`; the initial report uses the same prefix in its description.
|
||||||
Editing an existing comment does not send another message.
|
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:
|
To grant or remove the actual account reward, add a separate new comment:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -49,17 +63,21 @@ badges by copying a registered player's display name.
|
|||||||
|
|
||||||
## Deployment setup
|
## 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
|
before deploying. Production environment
|
||||||
variables in Vercel:
|
variables in Vercel:
|
||||||
|
|
||||||
- `LINEAR_API_KEY`: sensitive personal API key with Read, Create issues, and
|
- `LINEAR_API_KEY`: sensitive personal API key with **Read and Write** access to
|
||||||
Create comments access to the support team.
|
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_WEBHOOK_SECRET`: sensitive signing secret of the team's Linear webhook.
|
||||||
- `LINEAR_TEAM_ID`: support team UUID.
|
- `LINEAR_TEAM_ID`: support team UUID.
|
||||||
- `LINEAR_PROJECT_ID`: optional project UUID or Linear project identifier.
|
- `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
|
`https://minegeo.vercel.app/api/support/linear`. Redeploy after saving environment
|
||||||
variables. Keys stay server-side and must never be pasted into issue descriptions,
|
variables. Keys stay server-side and must never be pasted into issue descriptions,
|
||||||
client configuration, or source uploads.
|
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
|
within 60 seconds. Duplicate events have no effect. Delivery to Linear happens
|
||||||
after the HTTP response; opening the conversation retries interrupted or failed
|
after the HTTP response; opening the conversation retries interrupted or failed
|
||||||
delivery. The page shows saved/pending and delivered states.
|
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
|
Validation: `npm test`, `npm run typecheck`, `npm run build`, and
|
||||||
`node scripts/test-bug-reports-sql.mjs` (uses the isolated PGlite installation under
|
`node scripts/test-bug-reports-sql.mjs` (uses the isolated PGlite installation under
|
||||||
`.data/sql-verification`, as the other SQL verification scripts do).
|
`.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.
|
this integration are not backfilled.
|
||||||
|
|
||||||
In an open multiplayer lobby, the host can select **Publish party to Discord**.
|
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`.
|
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
|
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
|
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).
|
Multiplayer uses the Supabase migration described in [multiplayer.md](multiplayer.md).
|
||||||
Apply that migration to a new database before deploying the multiplayer web files.
|
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.
|
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
|
Map ZIP sharing requires `20261007145000_map_sharing.sql`. Imported maps stay
|
||||||
private and can be played without moderation; publication is blocked. See
|
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
|
Player bug reporting requires `20261008071054_bug_reports.sql` and the Linear
|
||||||
server credentials described in [bug-reports.md](bug-reports.md). Configure the
|
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`
|
Glow colors and user prefixes also require `20261008075322_profile_identity.sql`
|
||||||
before deploying the updated account, catalog, and multiplayer pages.
|
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 { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { after } from "next/server";
|
||||||
import { admin, required, user } from "./supabase";
|
import { admin, required, user } from "./supabase";
|
||||||
import { accountProfile } from "./profile";
|
import { accountProfile } from "./profile";
|
||||||
import { requestOrigin } from "./request-origin";
|
import { requestOrigin } from "./request-origin";
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
} from "../shared/bug-reports";
|
} from "../shared/bug-reports";
|
||||||
import { supportScreenshot } from "./support-images";
|
import { supportScreenshot } from "./support-images";
|
||||||
import { supportReady, syncBugAfterResponse } from "./support-sync";
|
import { supportReady, syncBugAfterResponse } from "./support-sync";
|
||||||
|
import { scheduleBugCleanup, reconcileBugStatus } from "./bug-lifecycle";
|
||||||
|
|
||||||
type Dependencies = {
|
type Dependencies = {
|
||||||
jsonBody: (request: NextRequest) => Promise<unknown>;
|
jsonBody: (request: NextRequest) => Promise<unknown>;
|
||||||
@@ -51,9 +53,14 @@ export async function bugReportApi(
|
|||||||
? record.owner_id === account?.id
|
? record.owner_id === account?.id
|
||||||
: !!saved && record.access_hash === hash;
|
: !!saved && record.access_hash === hash;
|
||||||
async function owned(id: string) {
|
async function owned(id: string) {
|
||||||
const record = required(
|
const result = await db
|
||||||
await db.from("bug_reports").select("*").eq("id", id).maybeSingle(),
|
.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);
|
if (!owns(record)) throw new ServiceError("Report not found", 404);
|
||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
@@ -79,6 +86,10 @@ export async function bugReportApi(
|
|||||||
description: record.description,
|
description: record.description,
|
||||||
page_path: record.page_path,
|
page_path: record.page_path,
|
||||||
state: record.state,
|
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,
|
linear_identifier: record.linear_identifier,
|
||||||
glow_requested: record.glow_requested,
|
glow_requested: record.glow_requested,
|
||||||
created_at: record.created_at,
|
created_at: record.created_at,
|
||||||
@@ -102,6 +113,12 @@ export async function bugReportApi(
|
|||||||
.order("created_at", { ascending: false })
|
.order("created_at", { ascending: false })
|
||||||
.limit(50),
|
.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({
|
return respond({
|
||||||
ready: supportReady(),
|
ready: supportReady(),
|
||||||
signedIn: !!account,
|
signedIn: !!account,
|
||||||
@@ -179,8 +196,14 @@ export async function bugReportApi(
|
|||||||
const id = z.string().uuid().parse(path[1]);
|
const id = z.string().uuid().parse(path[1]);
|
||||||
const record = await owned(id);
|
const record = await owned(id);
|
||||||
if (path.length === 2 && request.method === "GET") {
|
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);
|
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")
|
if (path.length !== 3 || request.method !== "POST")
|
||||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
@@ -243,6 +266,8 @@ export async function bugReportApi(
|
|||||||
return respond(await view(await owned(id)));
|
return respond(await view(await owned(id)));
|
||||||
}
|
}
|
||||||
if (path[2] === "messages") {
|
if (path[2] === "messages") {
|
||||||
|
if (record.state === "done")
|
||||||
|
throw new ServiceError("This bug report is Done", 409);
|
||||||
if (record.state === "draft")
|
if (record.state === "draft")
|
||||||
throw new ServiceError("Submit this report before sending messages", 409);
|
throw new ServiceError("Submit this report before sending messages", 409);
|
||||||
const body = bugMessageSchema.parse(await jsonBody(request));
|
const body = bugMessageSchema.parse(await jsonBody(request));
|
||||||
@@ -289,6 +314,36 @@ export async function bugReportApi(
|
|||||||
syncBugAfterResponse(id);
|
syncBugAfterResponse(id);
|
||||||
return respond(await view(record));
|
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 (path[2] === "claim") {
|
||||||
if (!account) throw new ServiceError("Sign in to link this report", 401);
|
if (!account) throw new ServiceError("Sign in to link this report", 401);
|
||||||
required(
|
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(
|
const players = required(
|
||||||
await db
|
await db
|
||||||
.from("multiplayer_players")
|
.from("multiplayer_players")
|
||||||
.select("id,display_name")
|
.select("id,display_name,account_id")
|
||||||
.eq("room_id", room.id)
|
.eq("room_id", room.id)
|
||||||
.eq("has_left", false),
|
.eq("has_left", false),
|
||||||
);
|
);
|
||||||
const host = players.find((p) => p.id === room.host_player_id);
|
const host = players.find((p) => p.id === room.host_player_id);
|
||||||
if (!host || players.length >= room.player_limit) return null;
|
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 {
|
return {
|
||||||
id: room.id,
|
id: room.id,
|
||||||
name: map.data.name,
|
name: map.data.name,
|
||||||
owner: host.display_name,
|
owner: hostName,
|
||||||
difficulty: room.difficulty,
|
difficulty: room.difficulty,
|
||||||
players: players.length,
|
players: players.length,
|
||||||
limit: room.player_limit,
|
limit: room.player_limit,
|
||||||
|
|||||||
@@ -3,7 +3,14 @@ import {
|
|||||||
type BugReportRecord,
|
type BugReportRecord,
|
||||||
} from "../shared/bug-reports";
|
} 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 {
|
export class LinearSupportClient {
|
||||||
constructor(
|
constructor(
|
||||||
private key: string,
|
private key: string,
|
||||||
@@ -34,13 +41,26 @@ export class LinearSupportClient {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return null;
|
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)
|
if (!response.ok || result.errors?.length || !result.data)
|
||||||
throw new Error("Support delivery is temporarily unavailable");
|
throw new Error("Support delivery is temporarily unavailable");
|
||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
async issue(id: string): Promise<Issue | null> {
|
async issue(id: string): Promise<Issue | null> {
|
||||||
const data = await this.graphql<{ issue: Issue }>(
|
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 },
|
{ id },
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
@@ -49,6 +69,47 @@ export class LinearSupportClient {
|
|||||||
throw new Error("Support issue team mismatch");
|
throw new Error("Support issue team mismatch");
|
||||||
return issue;
|
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> {
|
async createIssue(report: BugReportRecord): Promise<Issue> {
|
||||||
const existing = await this.issue(report.id);
|
const existing = await this.issue(report.id);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
@@ -65,6 +126,7 @@ export class LinearSupportClient {
|
|||||||
"",
|
"",
|
||||||
"Reply to the player with #player-report-send followed by your message.",
|
"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.",
|
"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}`,
|
`Report reference: ${report.id}`,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -61,30 +61,22 @@ export async function multiplayerApi(
|
|||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
if (request.method === "POST" && path.length === 1) {
|
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));
|
const body = createRoomSchema.parse(await jsonBody(request));
|
||||||
await limit("multiplayer:create:" + accessHash, 20, 3600);
|
await limit("multiplayer:create:" + accessHash, 20, 3600);
|
||||||
const account = await user();
|
const profile = await accountProfile(account);
|
||||||
if (account) await accountProfile(account);
|
|
||||||
const id = required(
|
const id = required(
|
||||||
await client.rpc("create_map_room", {
|
await client.rpc("create_map_room", {
|
||||||
p_dataset: body.datasetId,
|
p_dataset: body.datasetId,
|
||||||
p_difficulty: body.difficulty,
|
p_difficulty: body.difficulty,
|
||||||
p_name: body.displayName,
|
p_name: profile.display_name,
|
||||||
p_limit: body.playerLimit,
|
p_limit: body.playerLimit,
|
||||||
p_access_hash: accessHash,
|
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 });
|
return respond({ id });
|
||||||
}
|
}
|
||||||
const id = z.string().uuid().parse(path[1]);
|
const id = z.string().uuid().parse(path[1]);
|
||||||
@@ -151,11 +143,18 @@ export async function multiplayerApi(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
let payload: Record<string, string | number> = {};
|
let payload: Record<string, string | number> = {};
|
||||||
|
let joiningAccount: string | null = null;
|
||||||
if (request.method === "POST" && path.length === 3) {
|
if (request.method === "POST" && path.length === 3) {
|
||||||
const input = await jsonBody(request);
|
const input = await jsonBody(request);
|
||||||
if (action === "join") {
|
if (action === "join") {
|
||||||
payload = { name: joinRoomSchema.parse(input).displayName };
|
payload = { name: joinRoomSchema.parse(input).displayName };
|
||||||
await limit("multiplayer:join:" + accessHash, 40, 3600);
|
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 === "guess") payload = roomGuessSchema.parse(input);
|
||||||
else if (action === "ready" || action === "next")
|
else if (action === "ready" || action === "next")
|
||||||
payload = roomRoundSchema.parse(input);
|
payload = roomRoundSchema.parse(input);
|
||||||
@@ -172,7 +171,14 @@ export async function multiplayerApi(
|
|||||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
const room = required(
|
const room = required(
|
||||||
await client.rpc("multiplayer_action", {
|
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_room: id,
|
||||||
p_access_hash: accessHash,
|
p_access_hash: accessHash,
|
||||||
p_action: request.method === "GET" ? "view" : action,
|
p_action: request.method === "GET" ? "view" : action,
|
||||||
@@ -186,22 +192,6 @@ export async function multiplayerApi(
|
|||||||
console.error("Closed Discord party remains queued for cleanup.");
|
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") {
|
if (action !== "media") {
|
||||||
const appearances = await multiplayerAppearance(
|
const appearances = await multiplayerAppearance(
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { after } from "next/server";
|
import { after } from "next/server";
|
||||||
import { admin, required } from "./supabase";
|
import { admin, required } from "./supabase";
|
||||||
import { LinearSupportClient } from "./linear-support-client";
|
import { LinearSupportClient } from "./linear-support-client";
|
||||||
|
import { scheduleBugCleanup } from "./bug-lifecycle";
|
||||||
|
|
||||||
export function supportReady() {
|
export function supportReady() {
|
||||||
return !!(
|
return !!(
|
||||||
@@ -58,13 +59,20 @@ export async function syncBugReport(id: string) {
|
|||||||
.update({
|
.update({
|
||||||
issue_synced: true,
|
issue_synced: true,
|
||||||
linear_identifier: issue.identifier,
|
linear_identifier: issue.identifier,
|
||||||
state: "open",
|
|
||||||
})
|
})
|
||||||
.eq("id", id)
|
.eq("id", id)
|
||||||
.eq("lease_owner", worker)
|
.eq("lease_owner", worker)
|
||||||
.select("id")
|
.select("id")
|
||||||
.single(),
|
.single(),
|
||||||
);
|
);
|
||||||
|
required(
|
||||||
|
await db
|
||||||
|
.from("bug_reports")
|
||||||
|
.update({ state: "open" })
|
||||||
|
.eq("id", id)
|
||||||
|
.eq("state", "pending")
|
||||||
|
.select("id"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
required(
|
required(
|
||||||
await db
|
await db
|
||||||
@@ -102,6 +110,7 @@ export async function syncBugReport(id: string) {
|
|||||||
.eq("lease_owner", worker)
|
.eq("lease_owner", worker)
|
||||||
.select("id"),
|
.select("id"),
|
||||||
);
|
);
|
||||||
|
await scheduleBugCleanup(id);
|
||||||
} catch {
|
} catch {
|
||||||
console.error("Bug report delivery paused; the saved report will retry.");
|
console.error("Bug report delivery paused; the saved report will retry.");
|
||||||
required(
|
required(
|
||||||
|
|||||||
@@ -248,6 +248,133 @@ try {
|
|||||||
).rows[0];
|
).rows[0];
|
||||||
assert.equal(claimed.bug_finder, true);
|
assert.equal(claimed.bug_finder, true);
|
||||||
assert.equal(claimed.name_glow, false);
|
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(
|
console.log(
|
||||||
"Bug report SQL passed: private tables, lease retries, guest claims, staff reply deduplication, grant/revoke rewards and privilege isolation.",
|
"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)
|
.limit(1)
|
||||||
.single(),
|
.single(),
|
||||||
);
|
);
|
||||||
const host = session(),
|
const host = await account("contributor"),
|
||||||
friend = session(),
|
friend = session(),
|
||||||
outsider = session();
|
outsider = session();
|
||||||
const created = await host("rooms", {
|
const created = await host("rooms", {
|
||||||
datasetId: dataset.id,
|
datasetId: dataset.id,
|
||||||
difficulty: "normal",
|
difficulty: "normal",
|
||||||
displayName: "Discord host fixture",
|
|
||||||
playerLimit: 4,
|
playerLimit: 4,
|
||||||
});
|
});
|
||||||
rooms.push(created.id);
|
rooms.push(created.id);
|
||||||
await journal();
|
await journal();
|
||||||
const room = await host(`rooms/${created.id}`);
|
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(typeof room.discord.available, "boolean");
|
||||||
assert.equal(room.discord.state, null);
|
assert.equal(room.discord.state, null);
|
||||||
await outsider(`rooms/${created.id}/discord`, {}, "POST", 403);
|
await outsider(`rooms/${created.id}/discord`, {}, "POST", 403);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { randomUUID, randomBytes } from "node:crypto";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { chromium, expect } from "@playwright/test";
|
import { chromium, expect } from "@playwright/test";
|
||||||
import { createClient } from "@supabase/supabase-js";
|
import { createClient } from "@supabase/supabase-js";
|
||||||
@@ -9,7 +10,8 @@ const db = createClient(
|
|||||||
process.env.SUPABASE_SECRET_KEY,
|
process.env.SUPABASE_SECRET_KEY,
|
||||||
{ auth: { persistSession: false, autoRefreshToken: false } },
|
{ auth: { persistSession: false, autoRefreshToken: false } },
|
||||||
);
|
);
|
||||||
const rooms = [];
|
const rooms = [],
|
||||||
|
accounts = [];
|
||||||
const browser = await chromium.launch({
|
const browser = await chromium.launch({
|
||||||
headless: true,
|
headless: true,
|
||||||
args: ["--use-angle=swiftshader", "--enable-unsafe-swiftshader"],
|
args: ["--use-angle=swiftshader", "--enable-unsafe-swiftshader"],
|
||||||
@@ -34,6 +36,21 @@ try {
|
|||||||
});
|
});
|
||||||
const host = await desktop.newPage(),
|
const host = await desktop.newPage(),
|
||||||
friend = await mobile.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 = [];
|
const pageErrors = [];
|
||||||
for (const page of [host, friend])
|
for (const page of [host, friend])
|
||||||
page.on("pageerror", (error) => pageErrors.push(error.message));
|
page.on("pageerror", (error) => pageErrors.push(error.message));
|
||||||
@@ -42,7 +59,10 @@ try {
|
|||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
});
|
});
|
||||||
await host.getByRole("button", { name: "Multiplayer", exact: true }).click();
|
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.getByLabel("Player limit (including you)").selectOption("2");
|
||||||
await host
|
await host
|
||||||
.getByRole("button", { name: "Create multiplayer room", exact: true })
|
.getByRole("button", { name: "Create multiplayer room", exact: true })
|
||||||
@@ -248,4 +268,10 @@ try {
|
|||||||
if (error)
|
if (error)
|
||||||
throw new Error(`Browser test-room cleanup failed: ${error.message}`);
|
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 assert from "node:assert/strict";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID, randomBytes } from "node:crypto";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
import { createClient } from "@supabase/supabase-js";
|
import { createClient } from "@supabase/supabase-js";
|
||||||
if (existsSync(".env.local")) process.loadEnvFile(".env.local");
|
if (existsSync(".env.local")) process.loadEnvFile(".env.local");
|
||||||
const origin = new URL(process.argv[2] || "http://127.0.0.1:3007").origin;
|
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,
|
process.env.SUPABASE_SECRET_KEY,
|
||||||
{ auth: { persistSession: false, autoRefreshToken: false } },
|
{ 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() {
|
function player() {
|
||||||
let cookie;
|
const cookies = new Map();
|
||||||
return async (path, body, expected = 200) => {
|
return async (path, body, expected = 200) => {
|
||||||
const response = await fetch(`${origin}/api/v1/${path}`, {
|
const response = await fetch(`${origin}/api/v1/${path}`, {
|
||||||
method: body === undefined ? "GET" : "POST",
|
method: body === undefined ? "GET" : "POST",
|
||||||
@@ -19,35 +31,88 @@ function player() {
|
|||||||
...(body === undefined
|
...(body === undefined
|
||||||
? {}
|
? {}
|
||||||
: { origin, "content-type": "application/json" }),
|
: { origin, "content-type": "application/json" }),
|
||||||
...(cookie ? { cookie } : {}),
|
cookie: [...cookies]
|
||||||
|
.map(([key, value]) => `${key}=${value}`)
|
||||||
|
.join("; "),
|
||||||
},
|
},
|
||||||
body: body === undefined ? undefined : JSON.stringify(body),
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
signal: AbortSignal.timeout(30000),
|
signal: AbortSignal.timeout(30000),
|
||||||
});
|
});
|
||||||
if (response.headers.get("set-cookie"))
|
for (const cookie of response.headers.getSetCookie()) {
|
||||||
cookie = response.headers.get("set-cookie").split(";")[0];
|
const part = cookie.split(";")[0],
|
||||||
|
at = part.indexOf("=");
|
||||||
|
cookies.set(part.slice(0, at), part.slice(at + 1));
|
||||||
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
assert.equal(response.status, expected, JSON.stringify({ path, data }));
|
assert.equal(response.status, expected, JSON.stringify({ path, data }));
|
||||||
assert.match(response.headers.get("cache-control"), /no-store/);
|
assert.match(response.headers.get("cache-control"), /no-store/);
|
||||||
return data;
|
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 {
|
try {
|
||||||
const host = player(),
|
const host = await account("HTTP Host"),
|
||||||
friend = player(),
|
friend = player(),
|
||||||
outsider = player();
|
outsider = player();
|
||||||
const catalog = await host("catalog");
|
const catalog = await host("catalog");
|
||||||
const datasetId = catalog.maps[0]?.id;
|
const datasetId = catalog.maps[0]?.id;
|
||||||
assert.ok(datasetId, "A published map is required");
|
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", {
|
const created = await host("rooms", {
|
||||||
datasetId,
|
datasetId,
|
||||||
difficulty: "hard",
|
difficulty: "hard",
|
||||||
displayName: "HTTP Host",
|
|
||||||
playerLimit: 2,
|
playerLimit: 2,
|
||||||
});
|
});
|
||||||
rooms.push(created.id);
|
rooms.push(created.id);
|
||||||
|
await journal();
|
||||||
const path = `rooms/${created.id}`;
|
const path = `rooms/${created.id}`;
|
||||||
const hostView = await host(path);
|
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);
|
const preview = await outsider(path);
|
||||||
assert.equal(preview.you, null);
|
assert.equal(preview.you, null);
|
||||||
assert.deepEqual(preview.rounds, []);
|
assert.deepEqual(preview.rounds, []);
|
||||||
@@ -187,14 +252,14 @@ try {
|
|||||||
403,
|
403,
|
||||||
);
|
);
|
||||||
assert.equal(results.rounds.flatMap((round) => round.results).length, 10);
|
assert.equal(results.rounds.flatMap((round) => round.results).length, 10);
|
||||||
const capacityHost = player();
|
const capacityHost = await account("Capacity Host");
|
||||||
const full = await capacityHost("rooms", {
|
const full = await capacityHost("rooms", {
|
||||||
datasetId,
|
datasetId,
|
||||||
difficulty: "normal",
|
difficulty: "normal",
|
||||||
displayName: "Capacity Host",
|
|
||||||
playerLimit: 16,
|
playerLimit: 16,
|
||||||
});
|
});
|
||||||
rooms.push(full.id);
|
rooms.push(full.id);
|
||||||
|
await journal();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Array.from({ length: 14 }, (_, i) =>
|
Array.from({ length: 14 }, (_, i) =>
|
||||||
player()(`rooms/${full.id}/join`, { displayName: `Capacity ${i + 2}` }),
|
player()(`rooms/${full.id}/join`, { displayName: `Capacity ${i + 2}` }),
|
||||||
@@ -212,6 +277,11 @@ try {
|
|||||||
const rejection = attempts.find((result) => result.status === "rejected");
|
const rejection = attempts.find((result) => result.status === "rejected");
|
||||||
assert.match(rejection.reason.message, /This room is full/);
|
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}`)).playerCount, 16);
|
||||||
|
assert.equal(
|
||||||
|
(await capacityHost(`rooms/${full.id}/leave`, {})).status,
|
||||||
|
"closed",
|
||||||
|
"guests cannot become party hosts",
|
||||||
|
);
|
||||||
console.log(
|
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.",
|
"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);
|
.in("id", rooms);
|
||||||
if (error)
|
if (error)
|
||||||
throw new Error(`Multiplayer test-room cleanup failed: ${error.message}`);
|
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;
|
).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])
|
for (const cap of [1, 17, null])
|
||||||
await assert.rejects(create(cap), /Choose between 2 and 16 players/);
|
await assert.rejects(create(cap), /Choose between 2 and 16 players/);
|
||||||
const room = await create();
|
const room = await create();
|
||||||
@@ -255,6 +356,15 @@ try {
|
|||||||
await assert.rejects(chat(capacity, 16), /Chat permission/);
|
await assert.rejects(chat(capacity, 16), /Chat permission/);
|
||||||
assert.equal((await act(capacity, 16)).you, null);
|
assert.equal((await act(capacity, 16)).you, null);
|
||||||
await act(capacity, 17, "join", { name: "Replacement" });
|
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 act(capacity, 1, "leave");
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
chat(capacity, 1, "Left", randomUUID()),
|
chat(capacity, 1, "Left", randomUUID()),
|
||||||
|
|||||||
@@ -231,7 +231,6 @@ try {
|
|||||||
await api(player.context, "rooms", {
|
await api(player.context, "rooms", {
|
||||||
datasetId: map.id,
|
datasetId: map.id,
|
||||||
difficulty: "easy",
|
difficulty: "easy",
|
||||||
displayName: "Temporary host alias",
|
|
||||||
playerLimit: 3,
|
playerLimit: 3,
|
||||||
})
|
})
|
||||||
).id;
|
).id;
|
||||||
|
|||||||
@@ -41,7 +41,15 @@ export type BugReportRecord = {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
page_path: 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_expected: boolean;
|
||||||
screenshot_path: string | null;
|
screenshot_path: string | null;
|
||||||
linear_asset_url: string | null;
|
linear_asset_url: string | null;
|
||||||
@@ -76,6 +84,10 @@ export type BugReportView = Pick<
|
|||||||
| "linear_identifier"
|
| "linear_identifier"
|
||||||
| "glow_requested"
|
| "glow_requested"
|
||||||
| "created_at"
|
| "created_at"
|
||||||
|
| "closed_at"
|
||||||
|
| "delete_after"
|
||||||
|
| "linear_done_synced"
|
||||||
|
| "cleanup_error"
|
||||||
> & {
|
> & {
|
||||||
screenshot: string | null;
|
screenshot: string | null;
|
||||||
isGuest: boolean;
|
isGuest: boolean;
|
||||||
@@ -88,8 +100,10 @@ export type BugReportView = Pick<
|
|||||||
};
|
};
|
||||||
export type SupportCommand =
|
export type SupportCommand =
|
||||||
| { kind: "reply"; text: string }
|
| { kind: "reply"; text: string }
|
||||||
| { kind: "glow"; enabled: boolean };
|
| { kind: "glow"; enabled: boolean }
|
||||||
|
| { kind: "close" };
|
||||||
export function supportCommand(body: string): SupportCommand | null {
|
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());
|
const reply = /^#player-report-send\s+([\s\S]+)$/.exec(body.trim());
|
||||||
if (reply) {
|
if (reply) {
|
||||||
const parsed = text(1, 6000).safeParse(reply[1]);
|
const parsed = text(1, 6000).safeParse(reply[1]);
|
||||||
|
|||||||
@@ -702,6 +702,45 @@ export type Database = {
|
|||||||
[_ in never]: never;
|
[_ in never]: never;
|
||||||
};
|
};
|
||||||
Functions: {
|
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: {
|
claim_discord_cleanup: {
|
||||||
Args: { p_worker: string; p_target?: string | null };
|
Args: { p_worker: string; p_target?: string | null };
|
||||||
Returns: import("./discord").DiscordAnnouncement[];
|
Returns: import("./discord").DiscordAnnouncement[];
|
||||||
|
|||||||
@@ -830,5 +830,16 @@
|
|||||||
"Discord rate limit. Delivery will retry.": "Discord ограничил частоту запросов. Отправка будет повторена.",
|
"Discord rate limit. Delivery will retry.": "Discord ограничил частоту запросов. Отправка будет повторена.",
|
||||||
"Discord rejected this notification.": "Discord отклонил это объявление.",
|
"Discord rejected this notification.": "Discord отклонил это объявление.",
|
||||||
"Discord did not confirm delivery.": "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 { displayNameSchema } from "./account";
|
||||||
import type { Bounds, Difficulty } from "./protocol";
|
import type { Bounds, Difficulty } from "./protocol";
|
||||||
|
|
||||||
export const createRoomSchema = z.object({
|
export const createRoomSchema = z
|
||||||
|
.object({
|
||||||
datasetId: z.string().uuid(),
|
datasetId: z.string().uuid(),
|
||||||
difficulty: z.enum(["easy", "normal", "hard"]),
|
difficulty: z.enum(["easy", "normal", "hard"]),
|
||||||
displayName: displayNameSchema,
|
|
||||||
playerLimit: z.number().int().min(2).max(16),
|
playerLimit: z.number().int().min(2).max(16),
|
||||||
});
|
})
|
||||||
|
.strict();
|
||||||
export const joinRoomSchema = z.object({ displayName: displayNameSchema });
|
export const joinRoomSchema = z.object({ displayName: displayNameSchema });
|
||||||
export const chatMessageSchema = z.object({
|
export const chatMessageSchema = z.object({
|
||||||
clientId: z.string().uuid(),
|
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", () => {
|
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!"), {
|
assert.deepEqual(supportCommand("#player-report-send Dear User\nThanks!"), {
|
||||||
kind: "reply",
|
kind: "reply",
|
||||||
text: "Dear User\nThanks!",
|
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({
|
createRoomSchema.parse({
|
||||||
datasetId: map,
|
datasetId: map,
|
||||||
difficulty: "normal",
|
difficulty: "normal",
|
||||||
displayName: " Alex ",
|
|
||||||
playerLimit,
|
playerLimit,
|
||||||
}).displayName,
|
}).playerLimit,
|
||||||
"Alex",
|
playerLimit,
|
||||||
);
|
);
|
||||||
for (const playerLimit of [1, 17, 2.5, "16", null])
|
for (const playerLimit of [1, 17, 2.5, "16", null])
|
||||||
assert.equal(
|
assert.equal(
|
||||||
createRoomSchema.safeParse({
|
createRoomSchema.safeParse({
|
||||||
datasetId: map,
|
datasetId: map,
|
||||||
difficulty: "normal",
|
difficulty: "normal",
|
||||||
displayName: "Alex",
|
|
||||||
playerLimit,
|
playerLimit,
|
||||||
}).success,
|
}).success,
|
||||||
false,
|
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", () => {
|
test("multiplayer names and guesses reject hidden controls, invalid rounds, and non-finite positions", () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
joinRoomSchema.parse({ displayName: " Друг " }).displayName,
|
joinRoomSchema.parse({ displayName: " Друг " }).displayName,
|
||||||
|
|||||||
Reference in New Issue
Block a user