diff --git a/.oxlintrc.json b/.oxlintrc.json index 1110ce1..4bfdb2e 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -35,7 +35,9 @@ { // A promise the Worker drops can be cancelled when the response ends. "files": ["server/**/*.{js,ts}"], - "rules": { "typescript/no-floating-promises": "error" } + // A promise returned without await inside try skips its catch, so a failed room call escapes the Worker's + // error answer. + "rules": { "typescript/no-floating-promises": "error", "typescript/return-await": ["error", "in-try-catch"] } }, { // Test doubles pass methods around and spread ASCII test strings. diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 4e199e4..14155e0 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -380,7 +380,9 @@ On a channel the PixFray bot reads, `!accept`, `!duel` and `!top` also work as h command names and custom commands win over them), and a target may carry extra `@`, trailing `,.!?;:` and invisible characters (`@@bob`, `bob!`). The bot account is not a valid target: `!challenge @bot` answers "The bot doesn't fight (yet)! Name a rival: !challenge @name" and `!give @bot` "The bot doesn't take dollars. Give them to a rival!" (not on -the `BOT_DEBUG` test site). +the `BOT_DEBUG` test site). Lines from known chat bots (StreamElements, Nightbot, the PixFray bot itself) are ignored +with reason `chat_bot`; on the `BOT_DEBUG` test site the bot account's own `!fray e2e` and sparring moves still play, +while its typed chat lines stay ignored. `challenge` is an alias of `duel`, `fight` is an alias of `accept`, and duel needs a target. `attack` uses the player's `defaultAbility`. The optional target is allowed on every command. `rematch` ignores it and challenges the opponent of the sender's last finished duel (`lastOpponentId` on the player, saved as `profiles.last_opponent`), with the same rules as a challenge; with no such duel the reason is `no_previous_opponent`. A rematch answered by `!rematch` is a mutual challenge, so the duel starts. If someone has challenged the sender and is waiting for an answer, `!rematch` answers that challenge instead, so it works even when the challenger was not the sender's last opponent. @@ -564,6 +566,7 @@ export function parseArenaSnapshot(value), parseLooksMessage(value), parseLook(v // config, leaderboard and stored draft, the mod page's /api/admin and snapshots, the owner page's lists export function parseAnswer(r, parse, kind) // an ok answer whose body fails -> {ok:false, data:{error: UNREADABLE}} export function acceptedUnreadable(r) // true for that case (a 2xx it couldn't read): the write may have gone through +// the fighter page's profile save and shop purchase then say so and keep the button off, so a purchase can't run twice ``` `roomFetch` adds the internal secret and channel headers. `room.ctx.storage.sql` is the DO's SQLite. diff --git a/server/channel.ts b/server/channel.ts index ab7cc22..189fd12 100644 --- a/server/channel.ts +++ b/server/channel.ts @@ -208,6 +208,7 @@ type BotMessage = { bot?: unknown; timestamp?: unknown; quick?: unknown; + botPlays?: boolean; // set only by runCommand for the BOT_DEBUG bot account [field: string]: unknown; }; /** What the overlay sockets get: the state, or the saved looks that changed. */ @@ -2094,7 +2095,9 @@ export class ChannelRoom extends DurableObject { targetRaw: target ? "@" + target : "", ...(amount !== undefined ? { amount: String(amount) } : {}), }; - return (await this.runCommand(channel, input, origin, settings, { kind, subscriptionId })).json(); // runCommand's answer + return ( + await this.runCommand(channel, input, origin, settings, { kind, subscriptionId, botPlays: true }) + ).json(); // runCommand's answer } // Sparring partner (BOT_DEBUG): a challenge or rematch aimed at the bot is accepted, and dollars paid to it are paid back. @@ -2215,7 +2218,7 @@ export class ChannelRoom extends DurableObject { input: CommandInput, origin: string, settings: SeSettings, - source: { kind: string; subscriptionId: string }, + source: { kind: string; subscriptionId: string; botPlays?: boolean }, ): Promise { // includes() is true only for one of those strings const action = SE_ACTIONS.includes(input.action as string) ? (input.action as string) : ""; @@ -2398,6 +2401,8 @@ export class ChannelRoom extends DurableObject { messageId, timestamp: now, quick: true, + // The test site's bot account plays its own commands although its login is a known chat bot. + ...(this.env?.BOT_DEBUG === "1" && source.botPlays ? { botPlays: true } : {}), subscription: { id: source.subscriptionId }, event: { chatter_user_id: userId, @@ -3206,7 +3211,7 @@ export class ChannelRoom extends DurableObject { let main: { result: GameResult } | null = null; if (!userId || !/^[a-z0-9_]{1,25}$/.test(username) || !displayName) main = { result: { ok: false, reason: "invalid_event" } }; - else if (CHAT_BOTS.has(username)) main = { result: { ok: false, reason: "chat_bot" } }; + else if (CHAT_BOTS.has(username) && msg.botPlays !== true) main = { result: { ok: false, reason: "chat_bot" } }; else { const userProfile = this.getProfile(userId, state.config); // Registered players keep their saved look; other chatters take their Twitch name color. diff --git a/server/worker.ts b/server/worker.ts index 31f9db9..9239dac 100644 --- a/server/worker.ts +++ b/server/worker.ts @@ -415,7 +415,7 @@ async function handleDevtools( } async function staticCatalog(env: Env, url: URL): Promise { const r = await env.ASSETS.fetch(new Request(url.origin + "/assets/characters.json")); - return r.ok ? await r.json() : []; + return r.ok ? r.json() : []; } // The /play/ channel picker in one request: each listed channel with its top three ranked fighters (and the uploaded // looks they wear, which only that channel's catalog has), plus the built-in catalog once, and whether this site is full @@ -635,31 +635,32 @@ async function handle(request: Request, env: Env, ctx: ExecutionContext | undefi ); // Shop: the public list with this channel's prices (GET), and a signed-in viewer buying with PixFray dollars (POST). if (route === "shop") { - if (request.method === "GET" && !id) return internal(request, env, channel, "/shop"); + if (request.method === "GET" && !id) return await internal(request, env, channel, "/shop"); if (request.method !== "POST" || id) return json({ error: "Use GET or POST" }, 405); if (!user) return json({ error: "Sign in to shop" }, 401); const item = parseShopBuy(await bodyJson(request, 1000)); if (!item.ok) return json({ error: "Invalid item" }, 400); - return internal(request, env, channel, "/shop", { userId: user.id, ...item.value }); + return await internal(request, env, channel, "/shop", { userId: user.id, ...item.value }); } if (route === "profile") { if (!user) return json({ error: "Sign in to customize your profile" }, 401); if (id && (id !== "others" || request.method !== "GET")) return json({ error: "Not found" }, 404); if (id) return json({ fighters: await fightersElsewhere(request, env, user, channel) }); if (request.method === "GET") - return internal(request, env, channel, "/profile?userId=" + encodeURIComponent(user.id)); + return await internal(request, env, channel, "/profile?userId=" + encodeURIComponent(user.id)); if (request.method !== "POST") return json({ error: "Use GET or POST" }, 405); // stats/hat are optional; the room checks them against the saved wins (server/upgrades.ts). const look = parseProfileSave(await bodyJson(request, 4000)); - if (!look.ok) return refuseSave(request, env, channel, user, look.error, "Invalid profile fields", 400); + if (!look.ok) return await refuseSave(request, env, channel, user, look.error, "Invalid profile fields", 400); const { avatar, color, defaultAbility, stats, hat, pet, loadout } = look.value; const dynamicRes = await internal(request, env, channel, "/catalog"); const dynamic = dynamicRes.ok ? await dynamicRes.json() : []; const character = [...(await staticCatalog(env, url)), ...dynamic].find((x) => x.id === avatar); - if (!character) return refuseSave(request, env, channel, user, "unknown_character", "Unknown character", 400); + if (!character) + return await refuseSave(request, env, channel, user, "unknown_character", "Unknown character", 400); // An approved viewer sprite belongs to the viewer who made it. if (character.owner && character.owner !== user.id) - return refuseSave( + return await refuseSave( request, env, channel, @@ -775,7 +776,7 @@ async function handle(request: Request, env: Env, ctx: ExecutionContext | undefi return json({ error: "This channel uses StreamElements for chat. Choose Use StreamElements." }, 400); if (action === "connectChat" || action === "disconnectChat" || action === "useStreamElements") return await chatAction(env, url, channel, action, { takeover }); - return internal(request, env, channel, "/admin", { + return await internal(request, env, channel, "/admin", { ...admin.value.fields, actorId: user!.id, // canManage above means a signed-in user actorName: user!.displayName || user!.login, // as above @@ -788,14 +789,14 @@ async function handle(request: Request, env: Env, ctx: ExecutionContext | undefi } // Saved looks for the overlay: public, the same fields the leaderboard shows. if (route === "live") - return internal( + return await internal( request, env, channel, url.searchParams.get("role") === "overlay" ? "/live?role=overlay" : "/live", ); if (route === "looks") - return internal( + return await internal( request, env, channel, @@ -805,9 +806,9 @@ async function handle(request: Request, env: Env, ctx: ExecutionContext | undefi if (route === "leaderboard" && url.searchParams.get("private") === "1") { if (!(await access(env, user, channel)).canManage) return json({ error: "Moderator role required" }, user ? 403 : 401); - return internal(request, env, channel, "/leaderboard?private=1"); + return await internal(request, env, channel, "/leaderboard?private=1"); } - return internal(request, env, channel, "/" + route); + return await internal(request, env, channel, "/" + route); } catch (error) { // each `as HttpError` below: what bodyJson, setPaused and chatAction throw; another Error has no status (a thrown // null or undefined would fail here, as it does on main) diff --git a/src/dashboard.ts b/src/dashboard.ts index e048f56..8b409a5 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -564,6 +564,15 @@ async function buy( setStatus(out, "Not bought: your session expired. Sign in again to buy.", "error"); return; } + if (acceptedUnreadable(r)) { + // The dollars may be spent already, so the button stays off: a second click could buy it twice. + setStatus( + out, + "Your purchase may have gone through, but this page couldn't read the answer. Reload the page to check.", + "error", + ); + return; + } if (!r.ok) { btn.disabled = false; btn.dataset.confirm = ""; diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 2b207b9..e73afb9 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -484,6 +484,20 @@ test("sign-out reads only the cookie named mini_session, not one that ends in it assert.equal(f.entries.has("session:" + (await digest("1".repeat(64)))), false, "the real session is gone"); assert.equal(f.entries.has("session:" + (await digest(other))), true, "the look-alike cookie's session stays"); }); +test("a room route whose room throws gets the Worker's error answer, not a rejected fetch", async () => { + const f = environment(); + // Object.assign swaps in a throwing room without restating the binding's full type + Object.assign(f.env.ROOMS, { + get: () => ({ + fetch: async () => { + throw new Error("room down"); + }, + }), + }); + const r = await worker.fetch(req("/api/shop/nesszerra", "GET"), f.env); + assert.equal(r.status, 503); + assert.deepEqual(await r.json(), { error: "Service unavailable; check owner diagnostics" }); +}); test("an auth route that throws gets the Worker's error answer, not a rejected fetch", async () => { const f = environment(), cookie = await signedIn(f); diff --git a/tests/channel.test.ts b/tests/channel.test.ts index b2ab8ad..8df749c 100644 --- a/tests/channel.test.ts +++ b/tests/channel.test.ts @@ -2688,6 +2688,28 @@ test("debug bot: a challenge aimed at the bot is fought back, !fray spar challen assert.match((await plain.say("u1", "alice", "!fray spar")).reply, /gear up/i, "spar is just the help reply"); }); +test("debug bot: the real bot login (pixfray, a known chat bot) still plays every e2e step on the test site", async () => { + const r = botRoom({ BOT_DEBUG: "1" }); + await r.save("u1", "alice"); + await r.save("u2", "bob"); + await r.save("u9", "pixfray"); + await r.connectChat("sub-bot"); + await r.call("/dev-live", { method: "POST", body: { live: false } }); + const run = await r.say("u1", "alice", "!fray e2e", { mod: true }); + assert.match(run.replies[0], /^PixFray e2e, pixfray vs alice: 18 of 18 steps passed\.$/); + // Outside the bot's own commands, a chat line from pixfray is still ignored. + const open = () => r.readState("nesszerra").duels.filter((d) => d.status === "pending").length; + const before = open(), + realNow = Date.now; + Date.now = () => realNow() + 60_000; // past the reveal of the run's last fight + try { + await r.say("u9", "pixfray", "!challenge @bob"); + } finally { + Date.now = realNow; + } + assert.equal(open(), before, "pixfray's own typed !challenge opens no duel"); +}); + test("debug bot: !fray e2e plays every command once against the asker and posts which steps passed", async () => { const r = botRoom({ BOT_DEBUG: "1" }); await r.save("u1", "alice"); diff --git a/tests/ui.ts b/tests/ui.ts index 7f9ae18..5a9aea5 100644 --- a/tests/ui.ts +++ b/tests/ui.ts @@ -655,6 +655,40 @@ try { await context.close(); } + // 3a. A purchase the server took but whose answer the page can't read: the page says it may have gone through and + // doesn't offer the same buy again. + { + const { context, page } = await newPage(sizes[0]); + let buys = 0; + await page.route("**/api/shop/nesszerra", async (r) => { + if (r.request().method() === "GET") return r.continue(); + buys++; + return json(r, { ok: true, reason: "bought" }); // no dollars or owned: unreadable to the page + }); + await page.route("**/api/session", (r) => + json(r, { user, owner: false, configured: true, channels: ["nesszerra"], productionEnabled: false }), + ); + await page.route("**/api/access/nesszerra", (r) => json(r, { owner: false, moderator: false, canManage: false })); + await page.route("**/api/leaderboard/nesszerra*", (r) => json(r, board)); + await page.route("**/api/profile/nesszerra", (r) => + json(r, { ...board[1], hp: 100, registered: true, respawnAt: 0, lastSeen: now, dollars: 12 }), + ); + await page.goto(base + "/?channel=nesszerra"); + await page.waitForSelector("#save:not([hidden])"); + await page.locator("#tab-pets").click(); + const buyMouse = page.getByRole("button", { name: "Buy Mouse for $10" }); + await buyMouse.click(); + await buyMouse.click(); + await page.waitForFunction(() => /may have gone through/.test(document.querySelector("#pet-status")!.textContent)); // the page has this element + assert.equal( + await page.locator("#pet-status").textContent(), + "Your purchase may have gone through, but this page couldn't read the answer. Reload the page to check.", + ); + assert.equal(await buyMouse.isDisabled(), true, "no second buy while the first may have gone through"); + assert.equal(buys, 1); + await context.close(); + } + // 3b. First run: back from Twitch sign-in with no fighter, save one, then the next step until the first duel. for (const s of sizes) { const { context, page } = await newPage(s); @@ -817,7 +851,7 @@ try { // 4. Signed in but not a moderator -> admin gate. { - const { context, page } = await newPage(sizes[1]); + const { context, page } = await newPage(sizes[0]); await page.route("**/api/session", (r) => json(r, { user, owner: false, configured: true, channels: ["nesszerra"], productionEnabled: false }), ); @@ -845,7 +879,7 @@ try { // 4b. A moderator whose arena fails to load gets a gate with a retry, not controls that have no arena behind them. { - const { context, page } = await newPage(sizes[1]); + const { context, page } = await newPage(sizes[0]); await page.route("**/api/session", (r) => json(r, { user: mod, owner: false, configured: true, channels: ["nesszerra"], productionEnabled: false }), ); @@ -2260,7 +2294,7 @@ try { // 10. The viewer page of a channel that is off, or was never set up. { - const { context, page } = await newPage(sizes[1]); + const { context, page } = await newPage(sizes[0]); let off = "paused"; await page.route("**/api/state/*", (r) => json(r, { error: "off", off }, 403)); await page.goto(base + "/?channel=newstreamer"); @@ -2355,7 +2389,7 @@ try { } // 13. Every build slot bought: nothing is for sale, so the list holds the five builds and no stray text after them. { - const { context, page } = await newPage(sizes[1]); + const { context, page } = await newPage(sizes[0]); const profile = { ...board[1], avatar: "player",