Add account based multiplayer hosting and bug report retention

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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