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
12 changes: 9 additions & 3 deletions docs/CONTRACTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ fighter (saving and buying stay closed).
or missing, gets the error that route already gives when that upstream fails (the rows below), never a crash or a
coerced value. A valid answer behaves as before.
- Auth uses the `mini_session` cookie (HttpOnly), set by `/auth/callback`. It and its AuthStore `session:<hash>` record
last 30 days (`SESSION_S` in server/auth.ts), so viewers stay signed in between streams; signing out ends it.
last 30 days (`SESSION_S` in server/auth.ts), so viewers stay signed in between streams; signing out ends it. Only
a cookie named exactly `mini_session` counts, when reading the session and when signing out: one whose name ends in
it (`xmini_session`) is ignored.
- Every response carries `Strict-Transport-Security: max-age=31536000; includeSubDomains` on https. Pages get a
Content-Security-Policy with `script-src 'self'` plus Cloudflare's Web Analytics beacon (no inline script, no eval) and `frame-ancestors 'none'`; the overlay
has the same without the beacon or `frame-ancestors` (OBS and the /start demo load it), and sends `Cache-Control: no-transform` so
Expand All @@ -44,7 +46,7 @@ fighter (saving and buying stay closed).
channel's own account), `moderator` (Helix moderator check, needs the stored broadcaster token) and
`canManage = owner || broadcaster || moderator`.
- Unknown errors return 503 `{error:"Service unavailable; check owner diagnostics"}` and are
logged for the developer page.
logged for the developer page. That includes an error inside `/auth/*` (AuthStore down during sign-out, say).

## 2. HTTP routes

Expand Down Expand Up @@ -329,7 +331,8 @@ Bot replies and status (room table `bot_status`, one row):
- The Worker sends the room's replies with Helix Send Chat Message, then posts
`{results:[{sent, reason}]}` (10 max) to the room's internal `POST /bot-sent`. The room counts
sent and dropped replies; a drop is logged as a `warn` "bot reply dropped: <reason>" with the
reason in words (`botDropText` in server/eventsub.ts: duplicate, slow mode, 429, ...). Reminder
reason in words (`botDropText` in server/eventsub.ts: duplicate, slow mode, 429, ...; any other reason, such as
`constructor`, reads as itself). Reminder
posts do the same as "bot reminder sent" / "bot reminder dropped: ...".
- Replies the room holds back are logged as `warn` "bot reply held back: <reason>", at most once a
minute per reason: `reply_limit` (command replies stop at 14 in 30 s; the last 4 of the cap of 18 are kept for the bot's own result and expired-challenge lines), `unknown_subscription` (a message
Expand Down Expand Up @@ -547,9 +550,12 @@ Browser input is parsed once where it arrives, in the style of `server/parse.ts`
// the overlay's saved chat looks in localStorage and the demo's postMessage
export function parseArenaSnapshot(value), parseLooksMessage(value), parseLook(value), parseCosmeticSettings(text),
parseDemoMessage(value), keepGood(list, kind, parse), warnOnce(kind, error)
// parseDemoMessage answers null for a message with no demoPaused field (a browser extension's), which the demo
// ignores without a warning; one with a demoPaused that isn't true or false still warns once
// src/parse.ts (Lane B, bundled into the pages): /api/session, /api/access, the fighter page's profile, shop, state
// 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
```

`roomFetch` adds the internal secret and channel headers. `room.ctx.storage.sql` is the DO's SQLite.
Expand Down
2 changes: 1 addition & 1 deletion docs/PAGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
| Page | What it is |
|---|---|
| `/` | What PixFray is, with a duel playing. A returning viewer gets a "Back to <channel>" button. |
| `/?channel=<login>` | That channel's fighter page. A signed-in viewer can turn a picture into their own sprite under **Your own sprite** (an optional AI redraw first). It joins their character list once a mod approves it. Fighters are saved per channel: with none saved here, the page offers the look the viewer saved on another channel. Signed out, the page says the fighter shown is a starting one, and edits made before signing in come back after it. Punctuation after the login (`?channel=miolafff,`, as a chat app may add to a link) is dropped. |
| `/?channel=<login>` | That channel's fighter page. A signed-in viewer can turn a picture into their own sprite under **Your own sprite** (an optional AI redraw first). It joins their character list once a mod approves it. Fighters are saved per channel: with none saved here, the page offers the look the viewer saved on another channel. Signed out, the page says the fighter shown is a starting one, and edits made before signing in come back after it. When a save reaches the server but the page can't read the answer, it says the profile may have saved and to reload the page to check, instead of "Not saved". Punctuation after the login (`?channel=miolafff,`, as a chat app may add to a link) is dropped. |
| `/play/` | Picks the channel. |
| `/intro/` | Redirects to `/`. |
| `/start/` | Streamer sign-up. |
Expand Down
2 changes: 1 addition & 1 deletion scripts/port-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ const TSCONFIG = /^(?!public\/)(?:.*\/)?tsconfig[^/]*\.json$/;

/** Code that never ships: tests, scripts and the build configs. Their string literals may name a moved file's new
* path (a test reading `src/ui.ts`, the Worker entrypoint in cloudflare.config.ts). */
const NON_SHIPPING = /^(?:tests\/|scripts\/|cloudflare\.config\.ts$|vite\.config\.js$)/;
const NON_SHIPPING = /^(?:tests\/|scripts\/|cloudflare\.config\.ts$|vite\.config\.(?:js|ts)$)/;

/** The build config: when the base or the head has it, both are built and their output compared. */
const BUILD_CONFIG = "cloudflare.config.ts";
Expand Down
7 changes: 5 additions & 2 deletions server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,8 +244,11 @@ export const BOT_SCOPES = ["user:read:chat", "user:write:chat", "user:bot"];
export function configured(env: Env): boolean {
return !!(env.TWITCH_CLIENT_ID && env.TWITCH_CLIENT_SECRET && env.AUTH_SECRET && env.INTERNAL_SECRET);
}
// The raw mini_session cookie, matched at a cookie-name boundary so a cookie such as xmini_session isn't read as it.
const sessionCookie = (request: Request) =>
request.headers.get("Cookie")?.match(/(?:^|;\s*)mini_session=([a-f0-9]{64})(?:;|$)/)?.[1];
export async function session(request: Request, env: Env): Promise<Session | null> {
const raw = request.headers.get("Cookie")?.match(/(?:^|;\s*)mini_session=([a-f0-9]{64})(?:;|$)/)?.[1];
const raw = sessionCookie(request);
return raw ? record<Session | null>(env, "session:" + (await digest(raw))) : null;
}
export async function isOwner(env: Env, user: TwitchUser | null | undefined): Promise<boolean> {
Expand All @@ -271,7 +274,7 @@ export async function handleAuth(request: Request, env: Env): Promise<Response>
path = url.pathname;
if (path === "/auth/logout") {
if (request.method !== "POST") return new Response("Use POST", { status: 405 });
const raw = request.headers.get("Cookie")?.match(/mini_session=([a-f0-9]{64})/)?.[1];
const raw = sessionCookie(request);
if (raw) await record(env, "session:" + (await digest(raw)), null);
// the page reloads itself, so signing out stays on the same page and channel
return Response.json({ ok: true }, { headers: { "Set-Cookie": cookie("mini_session", "", 0) } });
Expand Down
33 changes: 18 additions & 15 deletions server/eventsub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,23 +464,26 @@ export function botMessage(message: unknown): string {
}
// Twitch's reason for a line the bot couldn't post, in words for the owner log, the admin page and "!fray debug".
// reason is a drop_reason code from Helix, or the error sendChatMessage threw ("... failed (403)").
const DROP_TEXT: Record<string, string> = {
msg_duplicate: "the same line twice within 30 s",
msg_rejected: "held by AutoMod",
msg_rejected_mandatory: "blocked by AutoMod or blocked terms",
msg_banned: "the bot is banned in this channel",
msg_timedout: "the bot is timed out",
msg_ratelimit: "Twitch rate limit",
msg_followersonly: "followers-only chat",
msg_subsonly: "subscriber-only chat",
msg_slowmode: "slow mode",
msg_emoteonly: "emote-only chat",
msg_verified_email: "the bot account needs a verified email",
empty: "nothing to send",
};
const DROP_TEXT = new Map(
Object.entries({
msg_duplicate: "the same line twice within 30 s",
msg_rejected: "held by AutoMod",
msg_rejected_mandatory: "blocked by AutoMod or blocked terms",
msg_banned: "the bot is banned in this channel",
msg_timedout: "the bot is timed out",
msg_ratelimit: "Twitch rate limit",
msg_followersonly: "followers-only chat",
msg_subsonly: "subscriber-only chat",
msg_slowmode: "slow mode",
msg_emoteonly: "emote-only chat",
msg_verified_email: "the bot account needs a verified email",
empty: "nothing to send",
}),
);
export function botDropText(reason: string | null | undefined): string {
reason = String(reason || "");
if (DROP_TEXT[reason]) return DROP_TEXT[reason] + " (" + reason + ")";
const text = DROP_TEXT.get(reason);
if (text) return text + " (" + reason + ")";
const status = /\((\d{3})\)/.exec(reason)?.[1];
if (status === "401") return "Twitch didn't accept the app token (401)";
if (status === "403")
Expand Down
2 changes: 1 addition & 1 deletion server/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,7 @@ async function handle(request: Request, env: Env, ctx: ExecutionContext | undefi
return json({ error: "Invalid dev token" }, 401);
if (mutating && !dev && request.headers.get("Origin") !== url.origin)
return json({ error: "Same-origin request required" }, 403);
if (path.startsWith("/auth/")) return handleAuth(request, env);
if (path.startsWith("/auth/")) return await handleAuth(request, env);
if (path === "/robots.txt" || path === "/sitemap.xml") return seoFile(url, path);
if (!path.startsWith("/api/")) return channelPageRedirect(env, url) || (await page(request, env, url));
// Public health check: up or not, nothing else. Version and Twitch setup are in the owner's /api/dev/diagnostics (worker.version, worker.twitchConfigured).
Expand Down
9 changes: 9 additions & 0 deletions src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import type { voxThumb as voxThumbFn, voxAll as voxAllFn } from "./voxthumb.ts";
import type { Stat, Stats } from "../server/upgrades.ts";
import type { CosmeticKind } from "../server/cosmetics.ts";
import {
acceptedUnreadable,
parseAnswer,
parseBought,
parseDraft,
Expand Down Expand Up @@ -1677,6 +1678,14 @@ form.addEventListener("submit", async (event) => {
);
return;
}
if (acceptedUnreadable(r)) {
setStatus(
status,
"Your profile may have saved, but this page couldn't read the answer. Reload the page to check.",
"error",
);
return;
}
if (!r.ok) {
setStatus(status, "Not saved: " + (REASONS[r.data?.error] || errorText(r)), "error");
return;
Expand Down
3 changes: 3 additions & 0 deletions src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ export function parseAnswer<T>(
return { ok: false, status: r.status, data: { error: UNREADABLE } };
}

/** An answer the server accepted (2xx) whose body this page couldn't read, so the write it answers may have gone through. */
export const acceptedUnreadable = (r: Answer<unknown>): boolean => !r.ok && r.status >= 200 && r.status < 300;

// ---------- field checks ----------

type Check = (v: unknown) => boolean;
Expand Down
2 changes: 1 addition & 1 deletion src/served/cosmetics.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Cosmetics drawn on stream (overlay.ts) and in the website preview (src/ui.ts). Looks only, no stat changes.
// The lists, prices and ownership live in server/cosmetics.ts; the ids here must match (tests/channel.test.mjs).
// The lists, prices and ownership live in server/cosmetics.ts; the ids here must match (tests/cosmetics.test.ts).
import { headFor } from "./hats.ts";
import type { Frame, Head } from "./hats.ts";

Expand Down
5 changes: 3 additions & 2 deletions src/served/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,8 +215,8 @@ export function readableColor(hex: string, minContrast = NAME_MIN_CONTRAST) {
return "#" + out.map((v) => v.toString(16).padStart(2, "0")).join("");
}

// The overlay's own code version: vite.config.js (versionPublicModules) writes ?v=<hash of public/*.js> on the overlay's
// script URL in the built overlay.html, so the hash changes only when overlay code changes, not on every deploy.
// The overlay's own code version: vite.config.ts (servedBuild) writes ?v=<hash of the served modules> on the overlay's
// script URL in the built overlay.html, so the hash changes only when served code changes, not on every deploy.
export const overlayVersionFromUrl = (url: string) => {
try {
return (new URL(url).searchParams.get("v") || "").match(/^[0-9a-f]{6,64}$/i)?.[0] || "";
Expand Down Expand Up @@ -1920,6 +1920,7 @@ async function start() {
addEventListener("message", (e) => {
if (e.origin !== location.origin) return; // another site's message: not for the demo
const message = parseDemoMessage(e.data);
if (!message) return; // not the demo's message (an extension's, say)
if (!message.ok) return warnOnce("a demo message", message.error);
demoPaused = message.value.demoPaused;
if (!demoPaused && !drawing) {
Expand Down
10 changes: 7 additions & 3 deletions src/served/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,8 +328,12 @@ export function parseCosmeticSettings(text: string | null): Parsed<CosmeticSetti
out[login] = { avatar: entry.avatar, color: entry.color };
return ok(out);
}
/** The site page that embeds the demo overlay posts { demoPaused } while it scrolls the demo out of view. */
export function parseDemoMessage(value: unknown): Parsed<{ demoPaused: boolean }> {
if (!isObject(value) || typeof value.demoPaused !== "boolean") return bad("demo message has no demoPaused switch");
/**
* The site page that embeds the demo overlay posts { demoPaused } while it scrolls the demo out of view. Answers null
* for a message without a demoPaused field: browser extensions post their own messages to the page.
*/
export function parseDemoMessage(value: unknown): Parsed<{ demoPaused: boolean }> | null {
if (!isObject(value) || !("demoPaused" in value)) return null;
if (typeof value.demoPaused !== "boolean") return bad("demo message has no demoPaused switch");
return ok({ demoPaused: value.demoPaused });
}
4 changes: 2 additions & 2 deletions src/served/upload.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Custom character uploader (Lane D). Browser only; no build step needed (served as-is from public/).
// Custom character uploader (Lane D). Browser only; vite.config.ts (servedBuild) serves it as /upload.js.
//
// Mount API, used by the /admin page (src/admin.js):
// Mount API, used by the /admin page (src/admin.ts):
// const { mountUpload } = await import('/upload.js');
// const handle = await mountUpload(root, { channel, usage, limits, items, refresh });
// root Element to render into (its children are replaced).
Expand Down
25 changes: 25 additions & 0 deletions tests/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,31 @@ test("sign-out clears the session and leaves the page where it is (the page relo
assert.match(must(r.headers.get("Set-Cookie")), /mini_session=;.*Max-Age=0/);
assert.equal(f.entries.has("session:" + (await digest("1".repeat(64)))), false);
});
test("sign-out reads only the cookie named mini_session, not one that ends in it", async () => {
const f = environment(),
cookie = await signedIn(f),
other = "2".repeat(64);
f.entries.set("session:" + (await digest(other)), { user: { id: "9", login: "other", displayName: "Other" } });
const r = await worker.fetch(req("/auth/logout", "POST", {}, "xmini_session=" + other + "; " + cookie), f.env);
assert.equal(r.status, 200);
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("an auth route that throws gets the Worker's error answer, not a rejected fetch", async () => {
const f = environment(),
cookie = await signedIn(f);
// Object.assign swaps in a throwing store without restating the binding's full type
Object.assign(f.env.AUTH, {
get: () => ({
fetch: async () => {
throw new Error("auth store down");
},
}),
});
const r = await worker.fetch(req("/auth/logout", "POST", {}, cookie), f.env);
assert.equal(r.status, 503);
assert.deepEqual(await r.json(), { error: "Service unavailable; check owner diagnostics" });
});
test("pages: / is the intro, /?channel= the fighter page, /play/ the picker, and /intro/ moves to /", async () => {
const f = environment();
// a fake asset binding that echoes the path it was asked for
Expand Down
18 changes: 18 additions & 0 deletions tests/browser-parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
UNREADABLE,
acceptedUnreadable,
parseAccess,
parseAdmin,
parseAdminSnapshot,
Expand Down Expand Up @@ -144,6 +145,8 @@ test("overlay looks push, saved chat looks and demo messages", (t) => {
ok: false,
error: "demo message has no demoPaused switch",
});
for (const other of ["hello", { source: "react-devtools-content-script" }, null, [true]])
assert.equal(parseDemoMessage(other), null, "a message that isn't the demo's is ignored: " + JSON.stringify(other));
});

test("parseAnswer: an ok body that fails turns into a failed answer; a failed answer keeps its body", (t) => {
Expand All @@ -166,6 +169,21 @@ test("parseAnswer: an ok body that fails turns into a failed answer; a failed an
});
});

test("acceptedUnreadable: only a 2xx answer whose body failed may have gone through", (t) => {
t.mock.method(console, "warn", () => {});
const save = (r: { ok: boolean; status: number; data: unknown }) =>
acceptedUnreadable(parseAnswer(r, parseProfileSaved, "the saved profile"));
assert.equal(save({ ok: true, status: 200, data: null }), true, "the save went through, the body didn't parse");
assert.equal(save({ ok: true, status: 200, data: { profile: "x" } }), true);
assert.equal(
save({ ok: true, status: 200, data: { profile: { wins: 0, losses: 0, elo: 1000, dollars: 0 } } }),
false,
"a save read in full",
);
assert.equal(save({ ok: false, status: 400, data: { error: "invalid_profile" } }), false, "a refused save");
assert.equal(save({ ok: false, status: 0, data: { error: "Network error" } }), false, "no answer at all");
});

test("session and access", () => {
const session = { user: { id: "1", login: "ann" }, owner: false, channels: ["x"] };
assert.deepEqual(parseSession(session), { ok: true, value: session });
Expand Down
Loading
Loading