diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8fddf67..3aee103 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,12 @@ Rules (checked by `tests/changelog.test.ts`):
and behind-the-scenes work.
0.1.0 to 0.7.1 were cut after the fact from git history.
+## 0.11.0 - 2026-10-20
+
+- Channel point rewards section on the mod page's Stream setup tab (`renderPoints` in `src/admin.ts`): the
+ broadcaster or owner allows the points permission, adds and deletes rewards; mods read rewards and redemptions.
+- `GET /api/admin/:channel` returns `pointsReady`, read from the scopes sealed with the stored broadcaster token.
+
## 0.10.0 - 2026-10-20
- `!changelog` bot and StreamElements command (`changelog` action, a read action). Its line comes from `LATEST_RELEASE`
diff --git a/admin/index.html b/admin/index.html
index 246d1a5..76448ad 100644
--- a/admin/index.html
+++ b/admin/index.html
@@ -291,6 +291,31 @@
Counters
+
+
Channel point rewards that add PixFray dollars
+
A viewer redeems the reward with channel points, the bot adds its dollars to their saved fighter and says their new wallet. A viewer without a saved fighter gets the points back. Twitch gives channel points only to Affiliates and Partners.
The overlay is blank in OBS
diff --git a/changelog/index.html b/changelog/index.html
index 08e41f9..3c7e91d 100644
--- a/changelog/index.html
+++ b/changelog/index.html
@@ -18,11 +18,12 @@
What's new in PixFray
-
What changed for players and streamers, newest first. PixFray is on version 0.10.0. Code changes are listed in CHANGELOG.md in the PixFray source.
+
What changed for players and streamers, newest first. PixFray is on version 0.11.0. Code changes are listed in CHANGELOG.md in the PixFray source.
+
+
0.11.0 Channel point rewards on the mod page
+
+
+
New Broadcasters set up channel point rewards on the mod page's Stream setup tab: allow the permission once, then add up to 5 rewards with their cost and the dollars they add, or delete them. Twitch offers channel points to Affiliates and Partners.
+
New The same section lists the latest redemptions and what happened to each, so mods can see who got dollars and who was refunded.
+
+
+
0.10.0 Changelog in chat
diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md
index bf391ce..f0bf50c 100644
--- a/docs/CONTRACTS.md
+++ b/docs/CONTRACTS.md
@@ -162,7 +162,7 @@ channel of that account only, so nobody can turn on someone else's channel.
- `fighters:` = `{channels:[login]}`, the channels where the viewer saved a fighter, newest save first, at most 8. A successful `POST /api/profile/:channel` moves the channel to the front; kept 90 days from the last save. Read by `GET /api/profile/:channel/others`.
- `modsconnected:` = `{at}`. Written when mod access is connected (`connect=mods`, a signup that grants `moderation:read`, or `connect=1`), and also by an admin page view that finds a stored token without one. Expires after 20 years (the AuthStore accepts expiries up to 21 years for `channel:`, `modsconnected:` and `bot:` keys, 100 days for everything else).
- `broadcaster:` = the sealed Twitch token plus `touched` (ms). It is kept 90 days from its last use: every save refreshes the expiry, the hourly token validation re-saves it, and an admin page view re-saves it when `touched` is more than 7 days old. A 401 from Twitch renews the access token with the stored refresh token. If the record expires or the refresh fails, mod checks stop (`modsReady:false`).
-- `GET /api/admin/:channel` adds `points` (`{rewards:[{rewardId, title, cost, dollars, subscriptionId, status, createdAt}], redemptions:[{id, at, rewardId, user, outcome, dollars, marked}] (the latest 20), limits}`) and `pointsLogin` (the points sign-in link). A reward's `status` is the subscription status Twitch answered when it was made (usually `webhook_callback_verification_pending`) until a revocation replaces it, so a pending status is not a fault.
+- `GET /api/admin/:channel` adds `points` (`{rewards:[{rewardId, title, cost, dollars, subscriptionId, status, createdAt}], redemptions:[{id, at, rewardId, user, outcome, dollars, marked}] (the latest 20), limits}`) `pointsLogin` (the points sign-in link) and `pointsReady` (true when the stored broadcaster token holds `channel:manage:redemptions`; the mod page shows **Allow channel point rewards** to the broadcaster or owner until it is). A reward's `status` is the subscription status Twitch answered when it was made (usually `webhook_callback_verification_pending`) until a revocation replaces it, so a pending status is not a fault.
- `GET /api/admin/:channel` adds `modsReady` (the `broadcaster:` record exists) and `modsLapsed` (`modsconnected:` exists but `broadcaster:` is gone). They are never both true. `modsLapsed` makes the admin page show "Expired" and "Reconnect mod access" instead of "Connect mod access" in Stream setup.
- `OWNER_TWITCH_ID` is a text binding, nesszerra's public Twitch id `445610108`, declared for prod and test in `cloudflare.config.ts`. When it is set, `isOwner` compares the session user's id to it and ignores the `owner:nesszerra` record (which expires 90 days after the last sign-in). It is not declared when `cf dev` runs with `MINI_LOCAL_TEST=1` (the test server), where seeded sessions use owner id 900001 and the `owner:nesszerra` record decides. A plain `cf dev` without that flag does declare it.
- An overlay on a paused or unknown channel draws nothing and reloads every 5 minutes. One that was
diff --git a/docs/DUELS.md b/docs/DUELS.md
index 6b6f67b..a59309c 100644
--- a/docs/DUELS.md
+++ b/docs/DUELS.md
@@ -72,6 +72,7 @@ The duel is rolled at once, the moment the challenge is accepted.
5 finished duels, at most $100 per stream, and only while the stream is live.
- On a channel that has PixFray channel point rewards, redeeming one adds its dollars to your saved fighter and
the bot says your new wallet. Without a saved fighter the redemption is refunded and the bot links the fighter page.
+ The broadcaster sets these rewards up on the mod page (docs/STREAMER_SETUP.md).
- Dollars show only to mods and the owner on the website.
## Ranked and unrated duels
diff --git a/docs/STREAMER_SETUP.md b/docs/STREAMER_SETUP.md
index 3a68285..9142377 100644
--- a/docs/STREAMER_SETUP.md
+++ b/docs/STREAMER_SETUP.md
@@ -81,6 +81,29 @@ Characters tab. Approving puts the viewer in their sprite; **Turn down** drops t
approved sprite can be removed later. Each viewer can send 6 a day; the AI redraw is limited to
3 per viewer and 60 per channel a day.
+## 6. Channel point rewards (optional)
+
+Channel point rewards let viewers spend their Twitch channel points on PixFray dollars for their
+saved fighter. Twitch offers channel points only to Affiliates and Partners; on other channels
+adding a reward fails and the page says why.
+
+1. On the mod page's **Stream setup** tab, find **Channel point rewards that add PixFray dollars**.
+2. Click **Allow channel point rewards** and approve the permission while signed in to Twitch as
+ yourself. You come back to the mod page.
+3. Fill in the reward's name (up to 45 characters), its cost in channel points (1 to 1,000,000) and
+ the dollars it adds (1 to 10,000), then click **Add reward**. It shows under channel points on
+ Twitch right away.
+
+A channel can have 5 PixFray rewards; delete one to add another. **Delete** removes the reward from
+Twitch too; dollars already added stay. Only you (or the site owner) can add or delete rewards.
+Moderators see the rewards and the recent redemptions without the controls.
+
+Each redemption adds the dollars and the bot says the viewer's new wallet. A viewer without a saved
+fighter gets the points back and the bot links the fighter page. **Recent redemptions** lists the
+latest 20. If a line says Twitch's request queue wasn't updated, the redemption is still pending in
+Twitch's queue: refund or complete it there. If Twitch stops letting PixFray manage the rewards,
+the page asks you to allow them again.
+
## Channels that use StreamElements
Channels that set PixFray up before the bot, and sites without a bot (`bot` unset in
diff --git a/package.json b/package.json
index c3c4068..94a24f9 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "mini-chat",
- "version": "0.10.0",
+ "version": "0.11.0",
"private": true,
"license": "MIT",
"type": "module",
diff --git a/public/dashboard.css b/public/dashboard.css
index 48c7bdb..7a3e280 100644
--- a/public/dashboard.css
+++ b/public/dashboard.css
@@ -1559,15 +1559,20 @@ html:has(.config-bar.dirty) {
/* mod tables: keep who, state and the row's button on screen; the detail columns are on wider screens */
table#duels :is(th, td):nth-child(n + 3):nth-child(-n + 5),
table#players :is(th, td):nth-child(n + 3):nth-child(-n + 6),
- table#ranks :is(th, td):nth-child(n + 4):nth-child(-n + 5) {
+ table#ranks :is(th, td):nth-child(n + 4):nth-child(-n + 5),
+ table#points-redemptions :is(th, td):nth-child(odd):not(:last-child) {
display: none;
}
- .table-wrap > table:is(#duels, #players, #ranks) {
+ .table-wrap > table:is(#duels, #players, #ranks, #points-table, #points-redemptions) {
width: 100%;
}
- .table-wrap > table:is(#duels, #players, #ranks) td:first-child {
+ .table-wrap > table:is(#duels, #players, #ranks, #points-table, #points-redemptions) td:first-child {
min-width: 0;
}
+ #points-table td:first-child,
+ #points-redemptions td {
+ white-space: normal;
+ }
.stat .value {
font-size: var(--text-xl);
overflow-wrap: anywhere;
diff --git a/server/release.ts b/server/release.ts
index 803e803..aa1ea16 100644
--- a/server/release.ts
+++ b/server/release.ts
@@ -1,3 +1,3 @@
// The newest release on the /changelog/ page (changelog/index.html), for the !changelog chat line.
// tests/changelog.test.ts keeps it equal to the page's newest release.
-export const LATEST_RELEASE = { version: "0.10.0", name: "Changelog in chat" };
+export const LATEST_RELEASE = { version: "0.11.0", name: "Channel point rewards on the mod page" };
diff --git a/server/worker.ts b/server/worker.ts
index 244e2c5..c64d1a1 100644
--- a/server/worker.ts
+++ b/server/worker.ts
@@ -11,6 +11,8 @@ import {
CHANNELS,
touchBroadcaster,
markModsConnected,
+ unseal,
+ POINTS_SCOPE,
} from "./auth.ts";
import { handleDeveloper, logWorkerError } from "./developer.ts";
import { backupChannels } from "./backups.ts";
@@ -792,6 +794,11 @@ async function handle(request: Request, env: Env, ctx: ExecutionContext | undefi
]);
const modsReady = !!broadcaster,
modsLapsed = !modsReady && !!marker;
+ // pointsReady: the stored token carries channel:manage:redemptions (the points sign-in), read from the scopes sealed
+ // with it; renewals keep them. Twitch can still refuse later, which createPointReward answers with the sign-in link.
+ const pointsReady = broadcaster
+ ? !!(await unseal<{ scopes?: string[] }>(env, broadcaster).catch(() => null))?.scopes?.includes(POINTS_SCOPE)
+ : false;
if (broadcaster)
await Promise.all([
touchBroadcaster(env, channel, broadcaster),
@@ -808,6 +815,7 @@ async function handle(request: Request, env: Env, ctx: ExecutionContext | undefi
modsReady,
modsLapsed,
pointsLogin: pointsLoginUrl(channel),
+ pointsReady,
channelState: state,
});
}
diff --git a/src/admin.ts b/src/admin.ts
index c528152..b6adba7 100644
--- a/src/admin.ts
+++ b/src/admin.ts
@@ -30,6 +30,7 @@ import type {
BotStatus,
ChatStatus,
LeaderRow as Fighter,
+ PointsPanel,
Se,
Session,
} from "./parse.ts";
@@ -437,6 +438,24 @@ async function init() {
url.searchParams.delete("mods");
history.replaceState(null, "", url.pathname + url.search + url.hash);
}
+ // Back from "Allow channel point rewards" (/auth/login?channel=&connect=points)
+ const points = new URLSearchParams(location.search).get("points");
+ const POINTS: Record = {
+ connected: "Channel point rewards are allowed. Add a reward below.",
+ denied: "The Twitch permission was cancelled, so PixFray can't make channel point rewards.",
+ };
+ // without ?points= this looks up "null", which isn't a key
+ if (POINTS[points as string]) {
+ // as above; element type from the page HTML
+ setStatus(
+ $("#points-status") as HTMLParagraphElement,
+ POINTS[points as string],
+ points === "connected" ? "ok" : "error",
+ );
+ const url = new URL(location.href);
+ url.searchParams.delete("points");
+ history.replaceState(null, "", url.pathname + url.search + url.hash);
+ }
// Back from "Add the PixFray bot" (/auth/login?connect=bot) or sign-up with channel:bot: connect chat through the bot now.
if (new URLSearchParams(location.search).get("bot") === "allowed") {
const url = new URL(location.href);
@@ -812,6 +831,7 @@ function renderAll() {
renderChecklist();
renderCheckinTest();
renderBotCommands();
+ renderPoints();
renderPower();
}
// Where chat comes from and whether it works. StreamElements counts as working only once a command has
@@ -2713,6 +2733,170 @@ async function commandAction(action: string, payload: Record, s
else button.disabled = false;
});
+// ---------- channel point rewards (server/points.ts; the broadcaster or the owner adds and deletes, mods read) ----------
+const POINT_OUTCOMES: Record = {
+ granted: "Dollars added",
+ refunded: "No saved fighter, points refunded",
+ no_fighter: "No saved fighter, not refunded",
+ ignored: "Not a PixFray reward",
+};
+const POINT_ERRORS: Record = {
+ invalid_title: "Name the reward with 1 to 45 characters",
+ invalid_cost: "Pick a cost from 1 to 1000000 channel points",
+ invalid_dollars: "Pick 1 to 10000 dollars",
+ points_max_rewards: "A channel can have 5 PixFray rewards. Delete one first",
+ points_not_connected: "Twitch needs the channel point permission again. Use Allow channel point rewards",
+ reward_not_found: "That reward is already gone",
+};
+let shownPoints = "";
+function renderPoints() {
+ // S.admin!: called from renderAll and pointsAction, after load()
+ const a = S.admin!,
+ p: PointsPanel | undefined = a.points,
+ // element type from the page HTML
+ section = $("#points") as HTMLElement;
+ section.hidden = !p;
+ if (!p) return;
+ const rewards = Array.isArray(p.rewards) ? p.rewards : [],
+ redemptions = Array.isArray(p.redemptions) ? p.redemptions : [],
+ max = p.limits?.maxRewards || 5,
+ owner = mayBroadcast(),
+ ready = !!a.pointsReady;
+ // element type from the page HTML
+ // only the broadcaster's own Twitch account can grant the permission, so the site owner elsewhere gets the note instead
+ ($("#points-connect") as HTMLDivElement).hidden = ready || !broadcasterHere();
+ // element type from the page HTML
+ ($("#points-connect-text") as HTMLParagraphElement).textContent = rewards.length
+ ? "Twitch no longer lets PixFray manage these rewards. Allow it again so redemptions add dollars and refunds work."
+ : "First let PixFray make rewards on your channel. Twitch asks for one more permission, then you come back here.";
+ // element type from the page HTML
+ ($("#points-login") as HTMLAnchorElement).href = a.pointsLogin || "#";
+ // element type from the page HTML
+ ($("#points-form") as HTMLFormElement).hidden = !(owner && ready);
+ // element type from the page HTML
+ ($("#points-add") as HTMLButtonElement).disabled = rewards.length >= max;
+ // element type from the page HTML
+ ($("#points-note") as HTMLParagraphElement).textContent = !owner
+ ? "Only " + CHANNEL + " can add or delete rewards." + (ready ? "" : " Channel point rewards aren't allowed yet.")
+ : !ready && !broadcasterHere()
+ ? CHANNEL + " has to allow channel point rewards, signed in to Twitch as themself."
+ : ready && rewards.length >= max
+ ? max + " of " + max + " rewards. Delete one to add another."
+ : "";
+ const key = JSON.stringify([p, owner]);
+ if (key === shownPoints) return;
+ shownPoints = key;
+ // element type from the page HTML
+ ($("#points-table tbody") as HTMLTableSectionElement).replaceChildren(
+ ...(rewards.length
+ ? rewards.map((r) =>
+ h(
+ "tr",
+ {},
+ h("td", {}, r.title),
+ h("td", { class: "num" }, r.cost.toLocaleString("en-US")),
+ h("td", { class: "num" }, "$" + r.dollars.toLocaleString("en-US")),
+ h(
+ "td",
+ {},
+ owner
+ ? h(
+ "button",
+ {
+ class: "btn btn-small btn-danger",
+ type: "button",
+ "data-reward": r.rewardId,
+ "data-title": r.title,
+ "aria-label": "Delete " + r.title,
+ },
+ "Delete",
+ )
+ : "",
+ ),
+ ),
+ )
+ : [h("tr", {}, h("td", { colspan: "4", class: "muted" }, "No rewards yet."))]),
+ );
+ const titles = new Map(rewards.map((r) => [r.rewardId, r.title]));
+ // element type from the page HTML
+ ($("#points-redemptions tbody") as HTMLTableSectionElement).replaceChildren(
+ ...(redemptions.length
+ ? redemptions.map((x) => {
+ // marked: 0 until Twitch answers, -1 when PixFray leaves it alone, else Twitch's HTTP status (1: no answer)
+ const unmarked = x.marked > 0 && (x.marked < 200 || x.marked > 299);
+ return h(
+ "tr",
+ {},
+ h("td", {}, dateTime(x.at)),
+ h("td", {}, x.user),
+ h("td", {}, titles.get(x.rewardId) || "A deleted reward"),
+ h(
+ "td",
+ {},
+ (POINT_OUTCOMES[x.outcome] || x.outcome) +
+ (unmarked ? ". Twitch's request queue wasn't updated, so check it there" : ""),
+ ),
+ h("td", { class: "num" }, x.dollars ? "$" + x.dollars.toLocaleString("en-US") : "0"),
+ );
+ })
+ : [h("tr", {}, h("td", { colspan: "5", class: "muted" }, "No redemptions yet."))]),
+ );
+}
+async function pointsAction(action: string, payload: Record) {
+ // element type from the page HTML
+ const status = $("#points-status") as HTMLParagraphElement;
+ setStatus(status, "Working…");
+ const r = await api("/api/admin/" + CHANNEL, { method: "POST", body: { action, payload } });
+ if (!r.ok) {
+ setStatus(status, "That didn't work: " + (POINT_ERRORS[r.data?.reason] || errorText(r)) + ".", "error");
+ if (r.data?.reason === "points_not_connected") await load();
+ return false;
+ }
+ // S.admin!: the section shows only after load()
+ S.admin!.points = { rewards: r.data.rewards, redemptions: r.data.redemptions, limits: r.data.limits };
+ renderPoints();
+ return true;
+}
+// element type from the page HTML
+($("#points-form") as HTMLFormElement).addEventListener("submit", async (e) => {
+ e.preventDefault();
+ // element type from the page HTML
+ const status = $("#points-status") as HTMLParagraphElement,
+ // element type from the page HTML
+ title = ($("#points-title-input") as HTMLInputElement).value.trim(),
+ // element type from the page HTML
+ cost = Number(($("#points-cost") as HTMLInputElement).value),
+ // element type from the page HTML
+ dollars = Number(($("#points-dollars") as HTMLInputElement).value),
+ // element type from the page HTML
+ button = $("#points-add") as HTMLButtonElement;
+ if (!title || title.length > 45) return setStatus(status, POINT_ERRORS.invalid_title + ".", "error");
+ if (!Number.isInteger(cost) || cost < 1 || cost > 1e6)
+ return setStatus(status, POINT_ERRORS.invalid_cost + ".", "error");
+ if (!Number.isInteger(dollars) || dollars < 1 || dollars > 1e4)
+ return setStatus(status, POINT_ERRORS.invalid_dollars + ".", "error");
+ button.disabled = true;
+ const ok = await pointsAction("createPointReward", { title, cost, dollars });
+ button.disabled = false;
+ renderPoints();
+ if (!ok) return;
+ // element type from the page HTML
+ ($("#points-form") as HTMLFormElement).reset();
+ setStatus(status, "Added " + title + ". It shows under channel points on Twitch now.", "ok");
+});
+// element type from the page HTML
+($("#points-table") as HTMLTableElement).addEventListener("click", async (e) => {
+ // clicks come from elements in the table; the delete carriers are buttons
+ const del = (e.target as Element).closest("[data-reward]");
+ if (!del || !confirm("Delete " + del.dataset.title + "? It disappears from Twitch too. Dollars already added stay."))
+ return;
+ del.disabled = true;
+ if (await pointsAction("deletePointReward", { rewardId: del.dataset.reward }))
+ // element type from the page HTML
+ setStatus($("#points-status") as HTMLParagraphElement, "Deleted " + del.dataset.title + ".", "ok");
+ else del.disabled = false;
+});
+
// ---------- turning PixFray off (signed-up channels; the broadcaster or the owner, and only the owner undoes the owner's off) ----------
const mayBroadcast = () => !!(S.access?.broadcaster || S.access?.owner);
const mayPower = () =>
diff --git a/src/parse.ts b/src/parse.ts
index 3d06674..4536c3a 100644
--- a/src/parse.ts
+++ b/src/parse.ts
@@ -440,6 +440,20 @@ export type BotCommands = {
commands: { name: string; reply: string }[];
counters: { name: string; value: number }[];
};
+/** Channel point rewards and the newest redemptions (server/points.ts pointsView). */
+export type PointsPanel = {
+ rewards: { rewardId: string; title: string; cost: number; dollars: number; status?: string; createdAt?: number }[];
+ redemptions: {
+ id: string;
+ at: number;
+ rewardId: string;
+ user: string;
+ outcome: string;
+ dollars: number;
+ marked: number;
+ }[];
+ limits: { titleMax: number; costMax: number; dollarsMax: number; maxRewards: number };
+};
/** GET /api/admin/:channel: the room's state plus the mod page's panels. */
export type Admin = {
revision: number;
@@ -461,6 +475,9 @@ export type Admin = {
checkinTestAllowed?: boolean;
checkinTest?: { until: number; by?: string } | null;
botCommands?: BotCommands;
+ points?: PointsPanel;
+ pointsLogin?: string;
+ pointsReady?: boolean;
channelState?: string;
};
const ADMIN: Spec = {
@@ -485,6 +502,9 @@ const ADMIN: Spec = {
customUsage: opt(isObject),
checkinTest: opt(orNull(isObject)),
botCommands: opt(isObject),
+ points: opt(isObject),
+ pointsLogin: opt(str),
+ pointsReady: opt(bool),
};
/** The room's lists, with each duel and player that doesn't read dropped. */
function roomLists(value: T): T {
diff --git a/tests/hardening.test.ts b/tests/hardening.test.ts
index a62ec94..103a8b1 100644
--- a/tests/hardening.test.ts
+++ b/tests/hardening.test.ts
@@ -563,6 +563,22 @@ test("admin snapshot: modsLapsed is true only when mod access was connected and
assert.deepEqual([s.modsReady, s.modsLapsed], [false, true], "and lapsed again once it expires");
});
+test("admin snapshot: pointsReady is true only when the stored token has channel:manage:redemptions", async () => {
+ const f = environment(),
+ cookie = await ownerCookie(f);
+ let s = await snapshot(f, cookie);
+ assert.deepEqual([s.pointsReady, s.pointsLogin], [false, "/auth/login?channel=nesszerra&connect=points"], "no token");
+ f.entries.set("broadcaster:nesszerra", await broadcasterToken(f, { scopes: ["moderation:read", "channel:bot"] }));
+ s = await snapshot(f, cookie);
+ assert.equal(s.pointsReady, false, "mod access only");
+ f.entries.set(
+ "broadcaster:nesszerra",
+ await broadcasterToken(f, { scopes: ["moderation:read", "channel:bot", "channel:manage:redemptions"] }),
+ );
+ s = await snapshot(f, cookie);
+ assert.equal(s.pointsReady, true, "after the points sign-in");
+});
+
test("admin snapshot: a token seen without a marker gets one, and viewing keeps a stale token alive", async () => {
const f = environment(),
cookie = await ownerCookie(f);
diff --git a/tests/ui.ts b/tests/ui.ts
index a980837..c8e1d29 100644
--- a/tests/ui.ts
+++ b/tests/ui.ts
@@ -2169,6 +2169,218 @@ try {
await context.close();
}
}
+ // 7d. Channel point rewards on a signed-up channel: the broadcaster allows them first, then adds and deletes rewards
+ // (five at most); a moderator reads the rewards and redemptions without controls.
+ {
+ const newstreamer = { id: "5505", login: "newstreamer", displayName: "NewStreamer" },
+ ch = "newstreamer",
+ login = "/auth/login?channel=newstreamer&connect=points";
+ const cases = [
+ {
+ name: "broadcaster, not allowed yet",
+ session: newstreamer,
+ access: { owner: false, broadcaster: true, moderator: false, canManage: true },
+ ready: false,
+ },
+ {
+ name: "broadcaster, allowed",
+ session: newstreamer,
+ access: { owner: false, broadcaster: true, moderator: false, canManage: true },
+ ready: true,
+ },
+ { name: "moderator", session: mod, access: { owner: false, moderator: true, canManage: true }, ready: true },
+ {
+ name: "site owner, not allowed yet",
+ session: { id: "9009", login: "nesszerra", displayName: "nesszerra" },
+ access: { owner: true, moderator: false, canManage: true },
+ ready: false,
+ },
+ ];
+ for (const c of cases)
+ for (const s of sizes) {
+ const { context, page } = await newPage(s);
+ const posts: Loose[] = []; // Loose: the POST bodies, read by field below
+ const limits = { titleMax: 45, costMax: 1000000, dollarsMax: 10000, maxRewards: 5 };
+ let rewards = [
+ {
+ rewardId: "rw1",
+ title: "PixFray $100",
+ cost: 500,
+ dollars: 100,
+ status: "enabled",
+ createdAt: now - 3600000,
+ },
+ ];
+ const redemptions = [
+ { id: "rd2", at: now - 60000, rewardId: "rw1", user: "alice", outcome: "granted", dollars: 100, marked: 200 },
+ { id: "rd1", at: now - 120000, rewardId: "gone", user: "bob", outcome: "refunded", dollars: 0, marked: 401 },
+ ];
+ await page.route("**/api/session", (r) =>
+ json(r, {
+ user: c.session,
+ owner: false,
+ configured: true,
+ channels: ["nesszerra"],
+ productionEnabled: false,
+ }),
+ );
+ await page.route("**/api/access/" + ch, (r) => json(r, c.access));
+ await page.route("**/api/leaderboard/" + ch + "*", (r) => json(r, []));
+ await page.route("**/api/assets/" + ch, (r) =>
+ json(r, {
+ items: [],
+ usage: { count: 0, limit: 8, bytes: 0 },
+ limits: { maxFrames: 24, frameSize: 128, maxAtlasBytes: 1572864, maxCharacters: 8 },
+ }),
+ );
+ await page.route("**/api/admin/" + ch, (r) => {
+ if (r.request().method() === "POST") {
+ const body = r.request().postDataJSON();
+ posts.push(body);
+ if (body.action === "createPointReward" && body.payload.title === "Taken")
+ return json(
+ r,
+ { ok: false, reason: "points_rejected", error: "Twitch refused the reward: duplicate" },
+ 400,
+ );
+ if (body.action === "createPointReward")
+ rewards = [
+ ...rewards,
+ { rewardId: "rw" + (rewards.length + 2), status: "enabled", createdAt: now, ...body.payload },
+ ];
+ if (body.action === "deletePointReward")
+ rewards = rewards.filter((x) => x.rewardId !== body.payload.rewardId);
+ return json(r, { ok: true, rewards, redemptions, limits });
+ }
+ return json(r, {
+ type: "snapshot",
+ channel: ch,
+ revision: 1,
+ paused: false,
+ chat: { connected: true, lastSeen: now, status: "enabled" },
+ config,
+ configVersion: 1,
+ round: 1,
+ players: [],
+ duels: [],
+ events: [],
+ chatStatus: {
+ connected: true,
+ source: "twitch",
+ status: "enabled",
+ subscriptionId: "sub-bot",
+ createdAt: now,
+ },
+ history: [{ version: 1, config, actorId: "system", at: now, note: "" }],
+ customUsage: { count: 0, limit: 8, bytes: 0 },
+ streamelements: null,
+ overlays: 1,
+ modsReady: true,
+ channelState: "on",
+ chatBot: { login: "pixfray", debug: false },
+ botCommands: { commands: [], counters: [], max: 50 },
+ botStatus: null,
+ points: { rewards, redemptions, limits },
+ pointsLogin: login,
+ pointsReady: c.ready,
+ access: c.access,
+ });
+ });
+ await page.goto(base + "/admin/?channel=" + ch + "#chat");
+ await page.waitForSelector("#points:not([hidden])");
+ assert.deepEqual(await page.locator("#points-table tbody tr").first().locator("td").allTextContents(), [
+ "PixFray $100",
+ "500",
+ "$100",
+ c.name === "moderator" ? "" : "Delete",
+ ]);
+ assert.deepEqual(
+ await page.locator("#points-redemptions tbody td:nth-child(4)").allTextContents(),
+ [
+ "Dollars added",
+ "No saved fighter, points refunded. Twitch's request queue wasn't updated, so check it there",
+ ],
+ c.name + ": outcomes, and a refund Twitch refused",
+ );
+ assert.equal(
+ await page.locator("#points-redemptions tbody td:nth-child(3)").nth(1).textContent(),
+ "A deleted reward",
+ );
+ if (c.name === "moderator") {
+ assert.equal(await page.locator("#points-form").isHidden(), true);
+ assert.equal(await page.locator("#points-connect").isHidden(), true);
+ assert.equal(await page.locator("#points-note").textContent(), "Only newstreamer can add or delete rewards.");
+ } else if (c.name === "site owner, not allowed yet") {
+ assert.equal(
+ await page.locator("#points-connect").isHidden(),
+ true,
+ "Twitch takes only the broadcaster's account",
+ );
+ assert.equal(await page.locator("#points-form").isHidden(), true);
+ assert.equal(
+ await page.locator("#points-note").textContent(),
+ "newstreamer has to allow channel point rewards, signed in to Twitch as themself.",
+ );
+ } else if (!c.ready) {
+ assert.equal(await page.locator("#points-form").isHidden(), true, "no form before the permission");
+ assert.equal(await page.locator("#points-login").getAttribute("href"), login);
+ assert.match(
+ (await page.locator("#points-connect-text").textContent())!, // element text, never null
+ /^Twitch no longer lets PixFray manage these rewards\./,
+ );
+ } else {
+ assert.equal(await page.locator("#points-connect").isHidden(), true);
+ await page.click("#points-add");
+ assert.equal(
+ await page.locator("#points-status").textContent(),
+ "Name the reward with 1 to 45 characters.",
+ "checked before sending",
+ );
+ await page.fill("#points-title-input", "Taken");
+ await page.fill("#points-cost", "100");
+ await page.fill("#points-dollars", "10");
+ await page.click("#points-add");
+ await page.waitForFunction(
+ () => /duplicate/.test(document.querySelector("#points-status")!.textContent), // the page has this element
+ );
+ for (const n of [2, 3, 4, 5]) {
+ await page.fill("#points-title-input", "Reward " + n);
+ await page.fill("#points-cost", String(n * 100));
+ await page.fill("#points-dollars", String(n * 10));
+ await page.click("#points-add");
+ await page.waitForFunction(
+ (t) => document.querySelector("#points-status")!.textContent.startsWith("Added " + t), // the page has this element
+ "Reward " + n,
+ );
+ }
+ assert.deepEqual(posts.at(-1), {
+ action: "createPointReward",
+ payload: { title: "Reward 5", cost: 500, dollars: 50 },
+ });
+ assert.equal(await page.locator("#points-table tbody tr").count(), 5);
+ assert.equal(await page.locator("#points-add").isDisabled(), true, "five rewards is the limit");
+ assert.equal(await page.locator("#points-note").textContent(), "5 of 5 rewards. Delete one to add another.");
+ await noOverflow(page, "channel points " + s.name);
+ for (const t of ["#points-table", "#points-redemptions"])
+ assert.ok(
+ await page
+ .locator(t)
+ .locator("xpath=..")
+ .evaluate((n) => n.scrollWidth <= n.clientWidth + 1),
+ t + " fits without sideways scrolling at " + s.name,
+ );
+ await page.locator("#points").screenshot({ path: shots + "/admin-points-" + s.name + ".png" });
+ page.once("dialog", (d) => d.accept());
+ await page.click('[data-reward="rw1"]');
+ await page.waitForFunction(
+ () => document.querySelector("#points-status")!.textContent === "Deleted PixFray $100.", // the page has this element
+ );
+ assert.deepEqual(posts.at(-1), { action: "deletePointReward", payload: { rewardId: "rw1" } });
+ assert.equal(await page.locator("#points-add").isDisabled(), false, "room for one more");
+ }
+ await context.close();
+ }
+ }
// 8. /start: open sign-up, and each reason Twitch sign-in can send someone back, at 1280/390.
for (const s of sizes) {
const { context, page } = await newPage(s);