Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion docs/CONTRACTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
11 changes: 8 additions & 3 deletions server/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -2094,7 +2095,9 @@ export class ChannelRoom extends DurableObject<Env> {
targetRaw: target ? "@" + target : "",
...(amount !== undefined ? { amount: String(amount) } : {}),
};
return (await this.runCommand(channel, input, origin, settings, { kind, subscriptionId })).json<CommandReply>(); // runCommand's answer
return (
await this.runCommand(channel, input, origin, settings, { kind, subscriptionId, botPlays: true })
).json<CommandReply>(); // 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.
Expand Down Expand Up @@ -2215,7 +2218,7 @@ export class ChannelRoom extends DurableObject<Env> {
input: CommandInput,
origin: string,
settings: SeSettings,
source: { kind: string; subscriptionId: string },
source: { kind: string; subscriptionId: string; botPlays?: boolean },
): Promise<Response> {
// includes() is true only for one of those strings
const action = SE_ACTIONS.includes(input.action as string) ? (input.action as string) : "";
Expand Down Expand Up @@ -2398,6 +2401,8 @@ export class ChannelRoom extends DurableObject<Env> {
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,
Expand Down Expand Up @@ -3206,7 +3211,7 @@ export class ChannelRoom extends DurableObject<Env> {
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.
Expand Down
25 changes: 13 additions & 12 deletions server/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,7 @@ async function handleDevtools(
}
async function staticCatalog(env: Env, url: URL): Promise<CatalogEntry[]> {
const r = await env.ASSETS.fetch(new Request(url.origin + "/assets/characters.json"));
return r.ok ? await r.json<CatalogEntry[]>() : [];
return r.ok ? r.json<CatalogEntry[]>() : [];
}
// 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
Expand Down Expand Up @@ -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<CatalogEntry[]>() : [];
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,
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "";
Expand Down
14 changes: 14 additions & 0 deletions tests/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
22 changes: 22 additions & 0 deletions tests/channel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
42 changes: 38 additions & 4 deletions tests/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 }),
);
Expand Down Expand Up @@ -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 }),
);
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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",
Expand Down
Loading