From 45e2a2c44cfaf8613397ca81b4533ecf2a0ba2af Mon Sep 17 00:00:00 2001 From: nesszerra <90105158+Finesssee@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:47:10 +0700 Subject: [PATCH] Channel point rewards that add PixFray dollars (backend) The broadcaster makes Twitch channel point rewards through two new admin actions; each reward gets its own redemption subscription. A redemption adds the reward's dollars to the viewer's saved fighter once (keyed by redemption id), marks it FULFILLED, and the bot posts the new wallet. Viewers with no saved fighter are refunded (CANCELED) and pointed at the fighter page. A separate points sign-in asks for channel:manage:redemptions. The mod page UI comes in a follow-up. --- AGENTS.md | 2 +- docs/CONTRACTS.md | 31 +++ docs/DUELS.md | 2 + server/auth.ts | 177 ++++++++++------ server/channel.ts | 5 + server/eventsub.ts | 53 ++++- server/points.ts | 493 +++++++++++++++++++++++++++++++++++++++++++ server/tracker.ts | 2 +- server/worker.ts | 93 +++++++- tests/points.test.ts | 371 ++++++++++++++++++++++++++++++++ tsconfig.node.json | 1 + 11 files changed, 1157 insertions(+), 73 deletions(-) create mode 100644 server/points.ts create mode 100644 tests/points.test.ts diff --git a/AGENTS.md b/AGENTS.md index a9e8ad8..63b918e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,7 +95,7 @@ Each one has happened here. Check for it by name before calling work done. |DEVTOOLS.md: test-site tools (bot fighters, scripted duels, alt account in real chat, OBS) |LIVE_FIX.md: release flow, owner page fix/deploy/rollback, backups |ASSET_LICENSES.md, CHARACTER_RESERVE.md: character and font sources|PROGRESSION_PLAN.md: historical -[Code]|server/: worker.ts routes|channel.ts ChannelRoom DO (state, bot commands, alarms)|game.ts rules, DEFAULT_CONFIG, validateConfigPatch|eventsub.ts webhooks, slimEvent, sendChat|auth.ts sessions, Helix mod checks|channels.ts registry|botcommands.ts|streamelements.ts|uploads.ts|sprites.ts|developer.ts|security.ts|ratelimit.ts|dedupe.ts|tracker.ts logs, counters|upgrades.ts pets.ts cosmetics.ts +[Code]|server/: worker.ts routes|channel.ts ChannelRoom DO (state, bot commands, alarms)|game.ts rules, DEFAULT_CONFIG, validateConfigPatch|eventsub.ts webhooks, slimEvent, sendChat|auth.ts sessions, Helix mod checks|channels.ts registry|botcommands.ts|streamelements.ts|uploads.ts|sprites.ts|developer.ts|security.ts|ratelimit.ts|dedupe.ts|tracker.ts logs|points.ts|upgrades.ts pets.ts cosmetics.ts |src/: admin.ts mod page|dashboard.ts channel page|start.ts sign-up|setup.ts|intro/ home page 3D|scrub.ts|sprite-maker.ts spritify.ts autosprite.ts|fighter3d.ts voxthumb.ts |src/served/: overlay.ts|arena-client.ts|chat.ts IRC|public/: overlay.html|dashboard.css tokens |tests/: *.test.ts unit|fakes.ts doubles|port/ port tools|smoke.ts|ui.ts arena-browser.ts|e2e-local.ts|chrome.ts diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 4460c40..5109213 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -159,6 +159,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 `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 @@ -189,6 +190,8 @@ channel of that account only, so nobody can turn on someone else's channel. | `rotateSeKey` | none | Handled by the Worker; broadcaster or owner only (403 `broadcaster_only`). Makes a new StreamElements key (New key) and clears the heard and rejected badges. | | `setSeNames` | `{names:{action:"!name"}}` | Handled by the Worker, any mod. Renames the StreamElements commands: each name is `!` plus 1 to 24 of `a-z0-9_`, and each action needs its own name (400 `{error}`). | | `useStreamElements` | none | Handled by the Worker, any mod. Records StreamElements as the chat source and deletes any Twitch subscription. | +| `createPointReward` | `{title, cost, dollars}` | Handled by the Worker; the broadcaster or the owner only (403 `broadcaster_only`). Makes a Twitch channel point reward that adds `dollars` PixFray dollars (server/points.ts, section 4). The title is 1 to 45 characters (400 `invalid_title`), the cost 1 to 1,000,000 points (`invalid_cost`), dollars 1 to 10,000 (`invalid_dollars`); at most 5 rewards per channel (409 `points_max_rewards`). Needs the broadcaster's points sign-in, `/auth/login?channel=&connect=points`, which asks for the earlier scopes plus `channel:manage:redemptions` (409 `{reason:"points_not_connected", reconnect}` until then). 403 `points_not_affiliate` ("Twitch only gives channel points to Affiliates and Partners, and is neither yet."), 400 `points_rejected` when Twitch refuses the title or cost, 502 when Twitch fails or refuses the redemption subscription (the new reward is then deleted at Twitch). Returns `{ok:true, rewards, redemptions, limits}`. | +| `deletePointReward` | `{rewardId}` | Handled by the Worker; the broadcaster or the owner only. Deletes the reward and its subscription at Twitch (a reward already gone at Twitch counts as deleted), then forgets it. 404 `reward_not_found`. Returns the same shape as `createPointReward`. | | `checkinTest` | `{value:boolean}` (top level) | Handled by the Worker, any mod, on the site channel (`site.defaultChannel`) only; any other channel gets 403. `true` turns check-in test mode on for 15 minutes (room table `checkin_test`, `{until, by}`), `false` ends it. While it's on and Twitch says the channel is offline, `!checkin` works out the check-in the next stream would give and answers "[Test, not saved] @name would check in: ..." without writing anything (no points, streak, check-in count or stream row), so it can be repeated. While the channel is live, check-ins count as usual. `GET /api/admin/:channel` returns `checkinTest` (`{until, by}` or `null`) and `checkinTestAllowed` (whether this channel shows the control). Returns `{ok:true, checkinTest}`. | ## 3. Overlay socket `/api/live/:channel` @@ -394,6 +397,26 @@ 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. +### Channel point redemptions + +`createPointReward` (section 2) subscribes each reward PixFray makes to +`channel.channel_points_custom_reward_redemption.add` v1 with condition `{broadcaster_user_id, reward_id}`, on the +same callback and secret. The Worker answers the verification challenge for this type like any other. +- A notification goes to the room of the event's `broadcaster_user_login` as `POST /points/redeem`. It is not gated + on the channel being on: a paused channel still grants, so spent points never sit unmarked in the streamer's queue. + A room 5xx answers 503 so Twitch retries; anything else answers 204. +- The room keys each redemption by its id (table `point_redemptions`, the latest 500 kept), so a retried + notification grants nothing twice. A reward the room doesn't hold is ignored. A saved fighter (matched by Twitch + user id) gets the reward's dollars, capped at 1,000,000; the bot says "@name got $N PixFray dollars from Title. + Wallet: $total." and the redemption is marked `FULFILLED`. A viewer with no saved fighter gets "@name needs a saved + fighter first: make one at /?channel=. Your channel points were refunded (redemption )." and the redemption is marked `CANCELED`, which refunds the points. +- Marking uses the broadcaster's token (`PATCH /channel_points/custom_rewards/redemptions`) after the 204. A failed + mark counts `points_mark_failed` and logs a warning; the grant stands. Outcomes count as `points_granted`, + `points_refunded`, `points_no_fighter` and `points_ignored`, and log under source `points`. +- A revocation goes to `POST /points/rewards {op:"revoked"}` in each room `rooms()` names; the room holding that + subscription sets the reward's `status`. + Local testing: `cf dev` started with `MINI_LOCAL_TEST=1` (scripts/test-all.ts does this) makes `connectChat` record a `local-<16 hex>` subscription without calling Twitch, only when `PUBLIC_ORIGIN` and the request host are loopback. `cloudflare.config.ts` refuses to build or @@ -569,6 +592,14 @@ export function logRoomError(room, error, context) // never export async function logWorkerError(env, error, context) // never throws ``` +```ts +// server/points.ts: channel point rewards. Worker side: createReward(env, {channel, title, cost, dollars, origin, url}), +// deleteReward(env, {channel, rewardId, subscriptionId, url}), markRedemption(env, redemption, status); room side: +export function ensurePointsSchema(sql), pointsView(room), redeem(room, redemption, {maxDollars, origin}) +export async function handleRoomPoints(room, request, {path, channel, maxDollars}) // DO: GET /points, POST /points/redeem, /points/marked, /points/rewards +export function parseRedemption(event), rewardProblem(title, cost, dollars), pointsLoginUrl(channel) +``` + Browser input is parsed once where it arrives, in the style of `server/parse.ts` (each parser returns `{ok:true, value}` or `{ok:false, error}`): diff --git a/docs/DUELS.md b/docs/DUELS.md index 2785022..fcdd710 100644 --- a/docs/DUELS.md +++ b/docs/DUELS.md @@ -65,6 +65,8 @@ The duel is rolled at once, the moment the challenge is accepted. - `!wallet` shows your dollars. - `!pay @name 10` passes them on (`!give` on channels with the PixFray chat bot). You can pay after 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. - Dollars show only to mods and the owner on the website. ## Ranked and unrated duels diff --git a/server/auth.ts b/server/auth.ts index da02119..6c06d56 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -33,10 +33,11 @@ export type OAuthPending = { connect?: boolean; connectMods?: boolean; connectBot?: boolean; + connectPoints?: boolean; next?: string; }; /** The broadcaster token as it is sealed in broadcaster:: Twitch's token answer plus the account and check. */ -type BroadcasterToken = TwitchTokens & { userId: string; validatedAt: number }; +type BroadcasterToken = TwitchTokens & { userId: string; validatedAt: number; scopes?: string[] }; /** A Helix /users row (the fields read here and in reviewReasons), as parseHelixUsers answers it. */ export type HelixUser = { id: string; @@ -241,6 +242,19 @@ export const CONNECT_SCOPES = ["moderation:read", "user:read:chat", "user:bot", // bot=1 (test site, CHAT_BOT): the BOT_LOGIN account lets the app read and send chat as it. Only its id is stored // (bot:twitch); the app token does the rest. connect=bot: a broadcaster allows the bot in their chat (channel:bot). export const BOT_SCOPES = ["user:read:chat", "user:write:chat", "user:bot"]; +// connect=points: a broadcaster lets PixFray make channel point rewards and fulfil or refund their redemptions. +export const POINTS_SCOPE = "channel:manage:redemptions"; +// The points sign-in replaces the stored broadcaster token, so it asks again for every scope the earlier sign-ins +// granted (the default channel's chat read included): Twitch may give the new token only what it asks for. +export function pointsScopes(env: Env, channel: string): string[] { + const base = + channel === site.defaultChannel + ? CONNECT_SCOPES + : env.CHAT_BOT === "1" + ? ["moderation:read", "channel:bot"] + : ["moderation:read"]; + return [...base, POINTS_SCOPE]; +} export function configured(env: Env): boolean { return !!(env.TWITCH_CLIENT_ID && env.TWITCH_CLIENT_SECRET && env.AUTH_SECRET && env.INTERNAL_SECRET); } @@ -311,7 +325,8 @@ export async function handleAuth(request: Request, env: Env): Promise return Response.json({ error: "PixFray is not enabled for this channel" }, { status: 403 }); const connect = url.searchParams.get("connect") === "1", connectBot = env.CHAT_BOT === "1" && url.searchParams.get("connect") === "bot", - connectMods = connectBot || url.searchParams.get("connect") === "mods"; + connectPoints = url.searchParams.get("connect") === "points", + connectMods = connectBot || connectPoints || url.searchParams.get("connect") === "mods"; if (connect && channel !== site.defaultChannel) return Response.json( { error: "Chat for this channel comes through StreamElements; no Twitch connection needed" }, @@ -323,15 +338,18 @@ export async function handleAuth(request: Request, env: Env): Promise connect, ...(connectMods ? { connectMods: true } : {}), ...(connectBot ? { connectBot: true } : {}), + ...(connectPoints ? { connectPoints: true } : {}), next, }; scope = connect ? CONNECT_SCOPES.join(" ") - : connectBot - ? "moderation:read channel:bot" - : connectMods - ? "moderation:read" - : ""; + : connectPoints + ? pointsScopes(env, channel).join(" ") + : connectBot + ? "moderation:read channel:bot" + : connectMods + ? "moderation:read" + : ""; } const nonce = await sealState(env, pending); const target = new URL("https://id.twitch.tv/oauth2/authorize"); @@ -358,7 +376,8 @@ export async function handleAuth(request: Request, env: Env): Promise if (pending?.signup && !url.searchParams.get("code")) return startPage(url.searchParams.get("error") === "access_denied" ? "denied" : "failed"); // !: a connectMods sign-in always carries its channel (the login branch above) - if (pending?.connectMods && !url.searchParams.get("code")) return adminPage(pending.channel!, "mods=denied"); + if (pending?.connectMods && !url.searchParams.get("code")) + return adminPage(pending.channel!, pending.connectPoints ? "points=denied" : "mods=denied"); if (!pending || !url.searchParams.get("code")) return Response.json({ error: "Authorization expired or denied" }, { status: 400 }); const tokenRes = await fetch("https://id.twitch.tv/oauth2/token", { @@ -470,12 +489,16 @@ export async function handleAuth(request: Request, env: Env): Promise if (pending.connectMods) { // !: a connectMods sign-in always carries its channel (the login branch above), here and below if (!(await isBroadcaster(env, user, pending.channel!))) return adminPage(pending.channel!, "mods=wrong_account"); - if (!modScope || (pending.connectBot && !validation.scopes?.includes("channel:bot"))) + if (pending.connectPoints) { + // !: as above + const missing = pointsScopes(env, pending.channel!).filter((x) => !validation.scopes?.includes(x)); + if (missing.length) return adminPage(pending.channel!, "points=denied"); // !: as above + } else if (!modScope || (pending.connectBot && !validation.scopes?.includes("channel:bot"))) return adminPage(pending.channel!, "mods=denied"); // !: as above await keepBroadcaster( env, pending.channel!, // !: as above - await seal(env, { ...tokens, userId: user.id, validatedAt: Date.now() }), + await seal(env, { ...tokens, userId: user.id, scopes: validation.scopes || [], validatedAt: Date.now() }), ); await markModsConnected(env, pending.channel!); // !: as above } @@ -483,12 +506,13 @@ export async function handleAuth(request: Request, env: Env): Promise await record(env, "session:" + (await digest(key)), { user, createdAt: Date.now() }, Date.now() + SESSION_S * 1000); // Back to the page the sign-in started on; the viewer and mod pages get the channel when the address lacks it. const to = new URL(safeNext(pending.next || "/"), url.origin); - for (const k of ["signed_in", "mods", "bot"]) to.searchParams.delete(k); + for (const k of ["signed_in", "mods", "bot", "points"]) to.searchParams.delete(k); if (pending.channel && ["/", "/admin/"].includes(to.pathname) && !to.searchParams.get("channel")) to.searchParams.set("channel", pending.channel); to.searchParams.set("signed_in", "1"); // bot=allowed: the admin page connects chat through the PixFray bot as soon as it opens if (pending.connectBot || (pending.signup && botScope)) to.searchParams.set("bot", "allowed"); + else if (pending.connectPoints) to.searchParams.set("points", "connected"); else if (pending.connectMods) to.searchParams.set("mods", "connected"); if (pending.bot) to.searchParams.set("bot", "connected"); if (pending.signup || pending.connectMods) to.hash = "chat"; @@ -513,6 +537,80 @@ function adminPage(channel: string, query: string): Response { }, }); } +/** A channel's stored broadcaster token (broadcaster:), from openBroadcaster. */ +export type Broadcaster = { + userId: string; + /** The scopes Twitch granted: from the hourly validation, else the token answer. */ + scopes: () => string[]; + /** Checks the token with Twitch at most hourly (renewing it when expired); `checked` says whether it ran now. */ + validate: () => Promise<{ scopes: string[]; checked: boolean }>; + /** A Helix call as the broadcaster; a 401 renews the token once and retries. */ + helix: (path: string, init?: RequestInit) => Promise; +}; +// The broadcaster token a sign-in stored for this channel, or null. Renewals and validations are saved back. +export async function openBroadcaster(env: Env, channel: string): Promise { + const encrypted = await record(env, "broadcaster:" + channel); + if (!encrypted) return null; + let token = await unseal(env, encrypted); + const keep = async () => keepBroadcaster(env, channel, await seal(env, token)); + const renew = async () => { + const r = await fetch("https://id.twitch.tv/oauth2/token", { + method: "POST", + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: token.refresh_token, + client_id: env.TWITCH_CLIENT_ID, + client_secret: env.TWITCH_CLIENT_SECRET, + }), + }); + const renewed = r.ok ? parseTwitchTokens(await r.json().catch(() => undefined)) : null; + if (!renewed?.ok) throw new Error("Broadcaster must reconnect Twitch"); + token = { ...token, ...renewed.value, validatedAt: 0 }; + }; + // Twitch's token answer lists its scopes as `scope`; a validation stores them as `scopes`. + const scopes = (): string[] => + token.scopes || (Array.isArray(token.scope) ? token.scope.filter((x) => typeof x === "string") : []); + return { + userId: token.userId, + scopes, + async validate() { + if (Date.now() - token.validatedAt <= 3600000) return { scopes: scopes(), checked: false }; + const check = () => + fetch("https://id.twitch.tv/oauth2/validate", { headers: { Authorization: "OAuth " + token.access_token } }); + let r = await check(); + if (r.status === 401) { + await renew(); + r = await check(); + } + const validated = r.ok ? parseTwitchValidation(await r.json().catch(() => undefined)) : null; + if (!validated?.ok) throw new Error("Twitch authorization unavailable"); + const v = validated.value; + if (v.user_id !== token.userId || v.client_id !== env.TWITCH_CLIENT_ID) + throw new Error("Broadcaster authorization is invalid"); + token = { ...token, scopes: v.scopes || [], validatedAt: Date.now() }; + await keep(); + return { scopes: scopes(), checked: true }; + }, + async helix(path, init = {}) { + const call = () => + fetch("https://api.twitch.tv/helix" + path, { + ...init, + headers: { + "Client-Id": env.TWITCH_CLIENT_ID, + Authorization: "Bearer " + token.access_token, + ...(init.body ? { "Content-Type": "application/json" } : {}), + }, + }); + let r = await call(); + if (r.status === 401) { + await renew(); + await keep(); + r = await call(); + } + return r; + }, + }; +} const verdict = (moderator: boolean): Access => ({ owner: false, moderator, @@ -530,62 +628,19 @@ export async function access(env: Env, user: TwitchUser | null | undefined, chan const cacheKey = "mod:" + channel + ":" + user.id, cached = await record(env, cacheKey); if (cached !== null) return verdict(cached === true); - const encrypted = await record(env, "broadcaster:" + channel); - if (!encrypted) + const broadcaster = await openBroadcaster(env, channel); + if (!broadcaster) return { owner: false, moderator: false, canManage: false, reason: "Broadcaster must connect moderator authorization", }; - let token = await unseal(env, encrypted); - const authHeaders = () => ({ "Client-Id": env.TWITCH_CLIENT_ID, Authorization: "Bearer " + token.access_token }); - const renew = async () => { - const r = await fetch("https://id.twitch.tv/oauth2/token", { - method: "POST", - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: token.refresh_token, - client_id: env.TWITCH_CLIENT_ID, - client_secret: env.TWITCH_CLIENT_SECRET, - }), - }); - const renewed = r.ok ? parseTwitchTokens(await r.json().catch(() => undefined)) : null; - if (!renewed?.ok) throw new Error("Broadcaster must reconnect Twitch"); - token = { ...token, ...renewed.value, validatedAt: 0 }; - }; - if (Date.now() - token.validatedAt > 3600000) { - let r = await fetch("https://id.twitch.tv/oauth2/validate", { - headers: { Authorization: "OAuth " + token.access_token }, - }); - if (r.status === 401) { - await renew(); - r = await fetch("https://id.twitch.tv/oauth2/validate", { - headers: { Authorization: "OAuth " + token.access_token }, - }); - } - const validated = r.ok ? parseTwitchValidation(await r.json().catch(() => undefined)) : null; - if (!validated?.ok) throw new Error("Twitch authorization unavailable"); - const v = validated.value; - if (v.user_id !== token.userId || v.client_id !== env.TWITCH_CLIENT_ID || !v.scopes?.includes("moderation:read")) - throw new Error("Broadcaster authorization is invalid"); - token.validatedAt = Date.now(); - await keepBroadcaster(env, channel, await seal(env, token)); - } - const checkMod = () => - fetch( - "https://api.twitch.tv/helix/moderation/moderators?broadcaster_id=" + - token.userId + - "&user_id=" + - encodeURIComponent(user.id), - { headers: authHeaders() }, - ); - let r = await checkMod(); - if (r.status === 401) { - await renew(); - await keepBroadcaster(env, channel, await seal(env, token)); - r = await checkMod(); - } + const { scopes, checked } = await broadcaster.validate(); + if (checked && !scopes.includes("moderation:read")) throw new Error("Broadcaster authorization is invalid"); + const r = await broadcaster.helix( + "/moderation/moderators?broadcaster_id=" + broadcaster.userId + "&user_id=" + encodeURIComponent(user.id), + ); const mods = r.ok ? parseModerators(await r.json().catch(() => undefined)) : null; if (!mods?.ok) throw new Error("Current moderator role cannot be verified"); const moderator = mods.value.includes(user.id); diff --git a/server/channel.ts b/server/channel.ts index d1a387e..3534c45 100644 --- a/server/channel.ts +++ b/server/channel.ts @@ -33,6 +33,7 @@ import { HATS, STATS, } from "./upgrades.ts"; +import { ensurePointsSchema, handleRoomPoints, pointsView } from "./points.ts"; import { ensurePetSchema, handleRoomPets, petCatalog, petOf, hatPrice, parseRoll, rollLegendary } from "./pets.ts"; import { ensureSpriteSchema, handleRoomSprites, spriteCatalog } from "./sprites.ts"; import { @@ -650,6 +651,7 @@ export class ChannelRoom extends DurableObject { ensureUploadSchema(sql); ensureDeveloperSchema(sql); ensureTrackerSchema(sql); + ensurePointsSchema(sql); sql.exec( "CREATE TABLE IF NOT EXISTS se_settings (id INTEGER PRIMARY KEY CHECK (id = 1), secret TEXT NOT NULL, names TEXT NOT NULL)", ); @@ -765,6 +767,8 @@ export class ChannelRoom extends DurableObject { if (path === "/catalog" || path === "/asset" || path.startsWith("/asset/")) return handleRoomAssets(this, request, { path, channel }); if (path.startsWith("/dev/")) return handleRoomDeveloper(this, request, { path, channel, url }); + if (path === "/points" || path.startsWith("/points/")) + return handleRoomPoints(this, request, { path, channel, maxDollars: MAX_DOLLARS }); if (path === "/pets" || path.startsWith("/pets/")) { // A deleted upload leaves the fighters using it with no pet; the stored profiles are cleared by handleRoomPets. let cleared: GameState | null = null; @@ -852,6 +856,7 @@ export class ChannelRoom extends DurableObject { checkinTest: this.checkinTest(), botCommands: this.botCommands(), botStatus: this.botStatus(), + points: pointsView(this), }); } diff --git a/server/eventsub.ts b/server/eventsub.ts index 55b5abc..ebd77d4 100644 --- a/server/eventsub.ts +++ b/server/eventsub.ts @@ -7,6 +7,8 @@ import type { Env } from "./env.ts"; import { isObject, parseEventsubEnvelope } from "./parse.ts"; import type { JsonObject } from "./parse.ts"; import type { ChatEvent, SendResult } from "./model.ts"; +import { parseRedemption, REDEMPTION_TYPE } from "./points.ts"; +import type { Redemption } from "./points.ts"; /** The bot's reply lines for one command, handed to deps.sendChat after the 204. */ export type BotChat = { channel: string; broadcasterId: string; senderId: string; messages: string[]; replyTo: string }; @@ -19,6 +21,8 @@ export type EventsubDeps = { findChannel?: (broadcasterId: string) => Promise; isEnabled?: (login: string) => Promise; botUserId?: () => Promise; + // after the 204: marks the redemption on Twitch and posts the bot line (server/points.ts) + onRedeemed?: (done: { redemption: Redemption; mark: "FULFILLED" | "CANCELED" | null; reply: string }) => void; now?: number; }; /** A Helix EventSub subscription (the fields read here). */ @@ -185,6 +189,7 @@ export async function handleEventsub( findChannel = async () => "", isEnabled = async () => false, botUserId = async () => "", + onRedeemed, now = Date.now(), }: EventsubDeps, ): Promise { @@ -251,6 +256,35 @@ export async function handleEventsub( if (subType === "channel.chat.message") for (const channel of await rooms()) await send(channel); return text(body.challenge, 200); } + // Channel point redemptions (server/points.ts). The redemption id keys the room's row, so a retry grants nothing twice. + if (subType === REDEMPTION_TYPE) { + const points = (channel: string, path: string, data: object) => + roomFetch(channel, path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (type === "revocation") { + for (const channel of await rooms()) + await points(channel, "/points/rewards", { + op: "revoked", + subscriptionId: message.subscription.id, + status: message.subscription.status, + }); + return new Response(null, { status: 204 }); + } + const r = type === "notification" ? parseRedemption(body.event) : null; + // No isEnabled gate: each subscription is for a reward PixFray made, and the room ignores rewards it doesn't hold. + // A paused channel still grants, so a viewer's spent points never sit unmarked in the streamer's queue. + if (!r) return new Response(null, { status: 204 }); + const res = await points(r.channel, "/points/redeem", { redemption: body.event, origin: origin(r.channel) }); + if (res.status >= 500) throw new Error("Room rejected redemption (" + res.status + ")"); + const d: { mark?: unknown; reply?: unknown } = res.ok ? await res.json().catch(() => ({})) : {}; + const mark = d.mark === "FULFILLED" || d.mark === "CANCELED" ? d.mark : null, + reply = typeof d.reply === "string" ? d.reply : ""; + if ((mark || reply) && onRedeemed) onRedeemed({ redemption: r, mark, reply }); + return new Response(null, { status: 204 }); + } if (type === "revocation") { if (subType === "channel.chat.message") for (const channel of await rooms()) await send(channel); // rooms ignore ids they don't own return new Response(null, { status: 204 }); @@ -320,7 +354,8 @@ export async function appToken(env: Env, fresh = false): Promise { await record(env, "app-token:twitch", await seal(env, { access_token: t.access_token, expiresAt }), expiresAt); return t.access_token; } -async function helix(env: Env, method: string, path: string, body?: unknown): Promise { +/** A Helix call with the app access token; a 401 fetches a fresh token once. */ +export async function appHelix(env: Env, method: string, path: string, body?: unknown): Promise { const call = async (token: string) => fetch(HELIX + path, { method, @@ -339,7 +374,7 @@ export async function listChatSubscriptions(env: Env): Promise { - const r = await helix(env, "DELETE", "/eventsub/subscriptions?id=" + encodeURIComponent(id)); +export async function deleteSubscription(env: Env, id: string): Promise { + const r = await appHelix(env, "DELETE", "/eventsub/subscriptions?id=" + encodeURIComponent(id)); if (!r.ok && r.status !== 404) throw fail("Twitch subscription delete failed (" + r.status + ")", 502); } const summary = (s: ChatSubscription): ChatSubscriptionSummary => ({ @@ -409,7 +444,7 @@ export async function connectChat( const keep = mine.find((s) => s.status === "enabled" && s.version === "1" && sameCondition(s)); for (const s of mine) if (s !== keep) await deleteSubscription(env, s.id); if (keep) return summary(keep); - const r = await helix(env, "POST", "/eventsub/subscriptions", { + const r = await appHelix(env, "POST", "/eventsub/subscriptions", { type: "channel.chat.message", version: "1", condition: { broadcaster_user_id: broadcasterId, user_id: userId }, @@ -505,11 +540,11 @@ export async function sendChatMessage( message, ...(replyTo ? { reply_parent_message_id: replyTo } : {}), }; - let r = await helix(env, "POST", "/chat/messages", body); + let r = await appHelix(env, "POST", "/chat/messages", body); for (const wait of [1100, 2200]) { if (r.status !== 429) break; await sleep(wait); - r = await helix(env, "POST", "/chat/messages", body); + r = await appHelix(env, "POST", "/chat/messages", body); } if (!r.ok) throw fail("Twitch send chat message failed (" + r.status + ")", 502); const d = (await r.json<{ data?: { is_sent?: boolean; drop_reason?: { code?: string } | null }[] }>()).data?.[0]; @@ -547,13 +582,13 @@ export async function sendChatMessages( } // A channel's immutable Twitch id from its login, or '' when Twitch doesn't know it. export async function twitchUserId(env: Env, login: string): Promise { - const r = await helix(env, "GET", "/users?login=" + encodeURIComponent(login)); + const r = await appHelix(env, "GET", "/users?login=" + encodeURIComponent(login)); if (!r.ok) throw fail("Twitch user lookup failed (" + r.status + ")", 502); return String((await r.json<{ data?: { id?: string }[] }>()).data?.[0]?.id || ""); } // !checkin: the channel's current stream as {id,startedAt}, or null when it is offline. Throws when Twitch can't be reached. export async function liveStream(env: Env, login: string): Promise<{ id: string; startedAt: number } | null> { - const r = await helix(env, "GET", "/streams?user_login=" + encodeURIComponent(login)); + const r = await appHelix(env, "GET", "/streams?user_login=" + encodeURIComponent(login)); if (!r.ok) throw fail("Twitch stream lookup failed (" + r.status + ")", 502); const s = (await r.json<{ data?: { id?: string; type?: string; started_at: string }[] }>()).data?.[0]; return s?.id && s.type === "live" ? { id: String(s.id), startedAt: Date.parse(s.started_at) || 0 } : null; diff --git a/server/points.ts b/server/points.ts new file mode 100644 index 0000000..a5be81c --- /dev/null +++ b/server/points.ts @@ -0,0 +1,493 @@ +// Twitch channel point rewards that add PixFray dollars (docs/CONTRACTS.md §2, §4, §7). The broadcaster allows it once +// at /auth/login?channel=&connect=points (channel:manage:redemptions). PixFray creates each reward with the +// broadcaster's token, so only PixFray can fulfil, refund or delete it, and subscribes to that reward's redemptions. +// A redemption adds the reward's dollars to the viewer's fighter and is marked FULFILLED; with no fighter it is +// CANCELED, which gives the viewer their points back. +import { openBroadcaster, POINTS_SCOPE, randomToken } from "./auth.ts"; +import { appHelix, deleteSubscription, eventsubSecret, localTestMode, EVENTSUB_PATH } from "./eventsub.ts"; +import { logRoomEvent } from "./developer.ts"; +import { count } from "./tracker.ts"; +import { isObject } from "./parse.ts"; +import type { JsonObject } from "./parse.ts"; +import type { Env } from "./env.ts"; + +export const REDEMPTION_TYPE = "channel.channel_points_custom_reward_redemption.add"; +/** Rewards one channel can have through PixFray (Twitch allows 50 of every kind). */ +export const MAX_POINT_REWARDS = 5; +export const POINT_LIMITS = { titleMax: 45, costMax: 1_000_000, dollarsMax: 10_000 }; +/** Redemption rows kept per room (the admin page shows the newest; ids older than this can't be retried by Twitch). */ +const KEEP_REDEMPTIONS = 500; + +export const pointsLoginUrl = (channel: string): string => "/auth/login?channel=" + channel + "&connect=points"; + +/** A reward as the room stores it and the admin page shows it. */ +export type PointReward = { + rewardId: string; + title: string; + cost: number; + dollars: number; + subscriptionId: string; + status: string; + createdAt: number; +}; +/** What happened to one redemption. `refunded` and `granted` are marked on Twitch; the rest are not. */ +export type RedeemOutcome = "granted" | "refunded" | "no_fighter" | "ignored"; +/** The fields of a redemption event PixFray reads (Twitch EventSub reference, redemption add). */ +export type Redemption = { + id: string; + broadcasterId: string; + channel: string; + userId: string; + userLogin: string; + userName: string; + rewardId: string; + status: string; +}; + +const fail = (message: string, status: number, extra: Record = {}) => + Object.assign(new Error(message), { status, ...extra }); +const str = (v: unknown, n: number): string => (typeof v === "string" ? v.slice(0, n) : ""); + +/** The redemption event Twitch sent, cut to the fields PixFray reads; null without an id, user or reward. */ +export function parseRedemption(ev: JsonObject | null): Redemption | null { + if (!ev) return null; + const reward = isObject(ev.reward) ? ev.reward : {}; + const r = { + id: str(ev.id, 100), + broadcasterId: str(ev.broadcaster_user_id, 100), + channel: str(ev.broadcaster_user_login, 64).toLowerCase(), + userId: str(ev.user_id, 100), + userLogin: str(ev.user_login, 64), + userName: str(ev.user_name, 100), + rewardId: str(reward.id, 100), + status: str(ev.status, 20), + }; + return r.id && r.userId && r.rewardId && r.channel ? r : null; +} + +/** Checks a new reward's title, cost and dollars; answers the reason code it fails with, or "". */ +export function rewardProblem(title: unknown, cost: unknown, dollars: unknown): string { + if (typeof title !== "string" || !title.trim() || title.trim().length > POINT_LIMITS.titleMax) return "invalid_title"; + if (!Number.isInteger(cost) || (cost as number) < 1 || (cost as number) > POINT_LIMITS.costMax) return "invalid_cost"; // as: checked integer + if (!Number.isInteger(dollars) || (dollars as number) < 1 || (dollars as number) > POINT_LIMITS.dollarsMax) + // as: checked integer + return "invalid_dollars"; + return ""; +} + +// ---------- Worker side: Helix with the broadcaster's token, EventSub with the app token ---------- + +// The broadcaster's token, refused with the points sign-in link when it is missing or lacks channel:manage:redemptions. +async function pointsToken(env: Env, channel: string) { + const reconnect = pointsLoginUrl(channel); + const refuse = () => + fail(channel + " must allow channel point rewards at " + reconnect, 409, { + reason: "points_not_connected", + reconnect, + }); + // an expired or revoked token throws from the renewal or the validation; both mean sign in again + const b = await openBroadcaster(env, channel).catch(() => null); + if (!b) throw refuse(); + const { scopes } = await b.validate().catch(() => ({ scopes: [] as string[] })); + if (!scopes.includes(POINTS_SCOPE)) throw refuse(); + return b; +} + +/** Creates the reward on Twitch and subscribes this site to its redemptions. Undoes the reward if the subscription fails. */ +export async function createReward( + env: Env, + { + channel, + title, + cost, + dollars, + origin, + url, + }: { channel: string; title: string; cost: number; dollars: number; origin: string; url: URL }, +): Promise { + if (localTestMode(env, url)) { + const id = "local-" + randomToken().slice(0, 16); + return { rewardId: id, title, cost, dollars, subscriptionId: id, status: "enabled", createdAt: Date.now() }; + } + if (!String(origin).startsWith("https://")) throw fail("PUBLIC_ORIGIN must be https for Twitch webhooks", 400); + const b = await pointsToken(env, channel); + // should_redemptions_skip_request_queue stays false: only an UNFULFILLED redemption can be refunded. + const r = await b.helix("/channel_points/custom_rewards?broadcaster_id=" + b.userId, { + method: "POST", + body: JSON.stringify({ + title, + cost, + prompt: "Adds $" + dollars + " PixFray dollars to your fighter. Save a fighter first, or your points come back.", + should_redemptions_skip_request_queue: false, + }), + }); + if (r.status === 403) + throw fail( + "Twitch only gives channel points to Affiliates and Partners, and " + channel + " is neither yet.", + 403, + { + reason: "points_not_affiliate", + }, + ); + if (r.status === 401) + throw fail(channel + " must allow channel point rewards at " + pointsLoginUrl(channel), 409, { + reason: "points_not_connected", + reconnect: pointsLoginUrl(channel), + }); + if (r.status === 400) { + const d = await r.json<{ message?: unknown }>().catch(() => ({}) as { message?: unknown }); + throw fail("Twitch refused the reward: " + String(d.message || "check the title and cost"), 400, { + reason: "points_rejected", + }); + } + if (!r.ok) throw fail("Twitch reward create failed (" + r.status + ")", 502); + const created = (await r.json<{ data?: { id?: string }[] }>()).data?.[0]; + if (!created?.id) throw fail("Twitch returned no reward", 502); + const rewardId = created.id; + // A new reward id has no subscription anywhere yet, so no other site can hold one. + const s = await appHelix(env, "POST", "/eventsub/subscriptions", { + type: REDEMPTION_TYPE, + version: "1", + condition: { broadcaster_user_id: b.userId, reward_id: rewardId }, + transport: { method: "webhook", callback: origin + EVENTSUB_PATH, secret: await eventsubSecret(env) }, + }); + const sub = s.ok ? (await s.json<{ data?: { id?: string; status?: string }[] }>()).data?.[0] : null; + if (!sub?.id) { + await b + .helix("/channel_points/custom_rewards?broadcaster_id=" + b.userId + "&id=" + encodeURIComponent(rewardId), { + method: "DELETE", + }) + .catch(() => null); + throw fail("Twitch refused the redemption subscription (" + s.status + "), so the reward was removed again", 502); + } + return { + rewardId, + title, + cost, + dollars, + subscriptionId: sub.id, + status: String(sub.status || "pending"), + createdAt: Date.now(), + }; +} + +/** Deletes the reward on Twitch (gone already is fine) and its subscription. */ +export async function deleteReward( + env: Env, + { channel, rewardId, subscriptionId, url }: { channel: string; rewardId: string; subscriptionId: string; url: URL }, +): Promise { + if (localTestMode(env, url) || rewardId.startsWith("local-")) return; + const b = await pointsToken(env, channel); + const r = await b.helix( + "/channel_points/custom_rewards?broadcaster_id=" + b.userId + "&id=" + encodeURIComponent(rewardId), + { method: "DELETE" }, + ); + if (!r.ok && r.status !== 404) throw fail("Twitch reward delete failed (" + r.status + ")", 502); + if (subscriptionId) await deleteSubscription(env, subscriptionId); +} + +/** Marks one redemption FULFILLED or CANCELED (a refund). Answers Twitch's status code; 0 when Twitch can't be reached. */ +export async function markRedemption( + env: Env, + { + channel, + rewardId, + redemptionId, + status, + }: { channel: string; rewardId: string; redemptionId: string; status: "FULFILLED" | "CANCELED" }, +): Promise { + try { + const b = await openBroadcaster(env, channel); + if (!b) return 401; + const r = await b.helix( + "/channel_points/custom_rewards/redemptions?broadcaster_id=" + + b.userId + + "&reward_id=" + + encodeURIComponent(rewardId) + + "&id=" + + encodeURIComponent(redemptionId), + { method: "PATCH", body: JSON.stringify({ status }) }, + ); + return r.status; + } catch { + return 0; + } +} + +// ---------- Room side ---------- + +type Room = { ctx: DurableObjectState }; +type RewardRow = { + reward_id: string; + title: string; + cost: number; + dollars: number; + subscription_id: string; + status: string; + created_at: number; +}; +type RedemptionRow = { + id: string; + at: number; + reward_id: string; + user_id: string; + user_login: string; + outcome: string; + dollars: number; + marked: number; +}; + +export function ensurePointsSchema(sql: SqlStorage): void { + sql.exec( + "CREATE TABLE IF NOT EXISTS point_rewards (reward_id TEXT PRIMARY KEY, title TEXT NOT NULL, cost INTEGER NOT NULL, dollars INTEGER NOT NULL, subscription_id TEXT NOT NULL, status TEXT NOT NULL, created_at INTEGER NOT NULL)", + ); + // id is Twitch's redemption id: a retried notification finds its row and changes nothing. + // marked: Twitch's answer to FULFILLED or CANCELED (0 until the Worker reports it, -1 when not marked at all). + sql.exec( + "CREATE TABLE IF NOT EXISTS point_redemptions (id TEXT PRIMARY KEY, at INTEGER NOT NULL, reward_id TEXT NOT NULL, user_id TEXT NOT NULL, user_login TEXT NOT NULL, outcome TEXT NOT NULL, dollars INTEGER NOT NULL, marked INTEGER NOT NULL DEFAULT 0)", + ); +} + +const toReward = (r: RewardRow): PointReward => ({ + rewardId: r.reward_id, + title: r.title, + cost: r.cost, + dollars: r.dollars, + subscriptionId: r.subscription_id, + status: r.status, + createdAt: r.created_at, +}); + +/** The channel's rewards and newest redemptions, for the admin page. */ +export function pointsView(room: Room) { + const sql = room.ctx.storage.sql; + return { + rewards: sql.exec("SELECT * FROM point_rewards ORDER BY created_at").toArray().map(toReward), + redemptions: sql + .exec("SELECT * FROM point_redemptions ORDER BY at DESC LIMIT 20") + .toArray() + .map((r) => ({ + id: r.id, + at: r.at, + rewardId: r.reward_id, + user: r.user_login, + outcome: r.outcome, + dollars: r.dollars, + marked: r.marked, + })), + limits: { ...POINT_LIMITS, maxRewards: MAX_POINT_REWARDS }, + }; +} + +/** The reply a redemption gets: the outcome, the line the bot posts, and how Twitch should mark it (null: leave it). */ +export type RedeemReply = { + ok: true; + outcome: RedeemOutcome; + repeat: boolean; + mark: "FULFILLED" | "CANCELED" | null; + reply: string; + dollars: number; +}; + +/** + * Adds a reward's dollars to the redeeming viewer's fighter, once per redemption id. A reward PixFray doesn't own is + * ignored (Twitch lets only the app that made it change it). With no fighter the redemption is refunded when it is still + * UNFULFILLED, which it always is for PixFray's rewards unless the streamer turned on skipping the request queue. + */ +export function redeem( + room: Room, + r: Redemption, + { maxDollars, origin }: { maxDollars: number; origin: string }, +): RedeemReply { + const sql = room.ctx.storage.sql; + const nothing = (outcome: RedeemOutcome, repeat = false): RedeemReply => ({ + ok: true, + outcome, + repeat, + mark: null, + reply: "", + dollars: 0, + }); + const reward = sql.exec("SELECT * FROM point_rewards WHERE reward_id = ?", r.rewardId).toArray()[0]; + if (!reward) return nothing("ignored"); + const seen = sql.exec("SELECT * FROM point_redemptions WHERE id = ?", r.id).toArray()[0]; + if (seen) return nothing(seen.outcome as RedeemOutcome, true); // as: only redeem writes outcome, from RedeemOutcome + const profile = sql + .exec<{ user_id: string; username: string; dollars: number }>( + "SELECT user_id, username, dollars FROM profiles WHERE user_id = ?", + r.userId, + ) + .toArray()[0]; + const unfulfilled = r.status === "unfulfilled"; + const outcome: RedeemOutcome = profile ? "granted" : unfulfilled ? "refunded" : "no_fighter"; + const name = r.userName || r.userLogin; + let total = 0, + reply = ""; + room.ctx.storage.transactionSync(() => { + if (profile) { + total = Math.max(0, Math.min(maxDollars, profile.dollars + reward.dollars)); + sql.exec("UPDATE profiles SET dollars = ? WHERE user_id = ?", total, r.userId); + } + sql.exec( + "INSERT INTO point_redemptions (id, at, reward_id, user_id, user_login, outcome, dollars, marked) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + r.id, + Date.now(), + r.rewardId, + r.userId, + r.userLogin, + outcome, + profile ? reward.dollars : 0, + unfulfilled ? 0 : -1, + ); + sql.exec( + "DELETE FROM point_redemptions WHERE id IN (SELECT id FROM point_redemptions ORDER BY at DESC LIMIT -1 OFFSET ?)", + KEEP_REDEMPTIONS, + ); + }); + // The wallet total, or the redemption id's tail, makes each line differ: Twitch drops a repeat within 30 s. + if (profile) reply = `@${name} got $${reward.dollars} PixFray dollars from ${reward.title}. Wallet: $${total}.`; + else + reply = + `@${name} needs a saved fighter first: make one at ${origin}/?channel=${r.channel}` + + (unfulfilled ? ". Your channel points were refunded" : "") + + ` (redemption ${r.id.slice(-4)}).`; + count(room, "points_" + outcome); + logRoomEvent(room, "points", `${r.userLogin} redeemed ${reward.title}: ${outcome}`, { + channel: r.channel, + redemptionId: r.id, + rewardId: r.rewardId, + userId: r.userId, + user: r.userLogin, + dollars: profile ? reward.dollars : 0, + wallet: total, + outcome, + }); + return { + ok: true, + outcome, + repeat: false, + mark: outcome === "granted" && unfulfilled ? "FULFILLED" : outcome === "refunded" ? "CANCELED" : null, + reply, + dollars: profile ? reward.dollars : 0, + }; +} + +/** Stores Twitch's answer to marking a redemption; a failure is counted and logged, since the streamer may then refund by hand. */ +export function noteMarked(room: Room, channel: string, redemptionId: string, status: number): void { + room.ctx.storage.sql.exec("UPDATE point_redemptions SET marked = ? WHERE id = ?", status || 1, redemptionId); + if (status >= 200 && status < 300) return; + count(room, "points_mark_failed"); + logRoomEvent(room, "warn", "channel point redemption not marked (" + (status || "network") + ")", { + channel, + redemptionId, + status, + }); +} + +/** Adds a reward the Worker created; refused past MAX_POINT_REWARDS. */ +export function addReward(room: Room, channel: string, reward: PointReward, actor: string): boolean { + const sql = room.ctx.storage.sql; + // COUNT(*) always returns one row + if (sql.exec<{ n: number }>("SELECT COUNT(*) AS n FROM point_rewards").toArray()[0]!.n >= MAX_POINT_REWARDS) + return false; + sql.exec( + "INSERT OR REPLACE INTO point_rewards (reward_id, title, cost, dollars, subscription_id, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + reward.rewardId, + reward.title, + reward.cost, + reward.dollars, + reward.subscriptionId, + reward.status, + reward.createdAt, + ); + logRoomEvent(room, "points", `${actor || "someone"} added reward ${reward.title}`, { + channel, + rewardId: reward.rewardId, + cost: reward.cost, + dollars: reward.dollars, + }); + return true; +} + +export function removeReward(room: Room, channel: string, rewardId: string, actor: string): void { + room.ctx.storage.sql.exec("DELETE FROM point_rewards WHERE reward_id = ?", rewardId); + logRoomEvent(room, "points", `${actor || "someone"} removed reward ${rewardId}`, { channel, rewardId }); +} + +/** A revoked subscription (the broadcaster removed PixFray's access, or Twitch gave up on the callback). */ +export function noteRevoked(room: Room, channel: string, subscriptionId: string, status: string): void { + const cut = room.ctx.storage.sql.exec( + "UPDATE point_rewards SET status = ? WHERE subscription_id = ?", + status || "revoked", + subscriptionId, + ); + if (cut.rowsWritten) + logRoomEvent(room, "warn", "channel point subscription revoked: " + status, { channel, subscriptionId, status }); +} + +const reply = (data: unknown, status = 200): Response => + new Response(JSON.stringify(data), { status, headers: { "Content-Type": "application/json" } }); +const refuse = (status: number, reason: string): Response => reply({ ok: false, reason, error: reason }, status); + +function parseReward(v: unknown): PointReward | null { + if (!isObject(v)) return null; + const { rewardId, title, cost, dollars, subscriptionId, status, createdAt } = v; + if (typeof rewardId !== "string" || !rewardId || typeof subscriptionId !== "string") return null; + if (rewardProblem(title, cost, dollars) || typeof status !== "string" || typeof createdAt !== "number") return null; + // rewardProblem checked title is a string and cost and dollars integers + return { + rewardId, + title: (title as string).trim(), + cost: cost as number, + dollars: dollars as number, + subscriptionId, + status, + createdAt, + }; +} + +/** + * The room's Worker-only points routes: GET /points (the admin view); POST /points/rewards {op: add|remove|revoked}; + * POST /points/redeem {redemption, origin}; POST /points/marked {redemptionId, status}. + */ +export async function handleRoomPoints( + room: Room, + request: Request, + { path, channel, maxDollars }: { path: string; channel: string; maxDollars: number }, +): Promise { + if (path === "/points" && request.method === "GET") return reply(pointsView(room)); + if (request.method !== "POST") return refuse(405, "method_not_allowed"); + const body: unknown = await request.json().catch(() => null); + if (!isObject(body)) return refuse(400, "invalid_body"); + if (path === "/points/redeem") { + const r = parseRedemption(isObject(body.redemption) ? body.redemption : null); + if (!r || r.channel !== channel) return refuse(400, "invalid_redemption"); + return reply(redeem(room, r, { maxDollars, origin: typeof body.origin === "string" ? body.origin : "" })); + } + if (path === "/points/marked") { + const { redemptionId, status } = body; + if (typeof redemptionId !== "string" || !Number.isInteger(status)) return refuse(400, "invalid_body"); + noteMarked(room, channel, redemptionId, status as number); // as: checked integer + return reply({ ok: true }); + } + if (path === "/points/rewards") { + const actor = typeof body.actor === "string" ? body.actor.slice(0, 48) : ""; + if (body.op === "add") { + const reward = parseReward(body.reward); + if (!reward) return refuse(400, "invalid_reward"); + return addReward(room, channel, reward, actor) + ? reply({ ok: true, ...pointsView(room) }) + : refuse(409, "points_max_rewards"); + } + if (body.op === "remove" && typeof body.rewardId === "string") { + removeReward(room, channel, body.rewardId, actor); + return reply({ ok: true, ...pointsView(room) }); + } + if (body.op === "revoked" && typeof body.subscriptionId === "string") { + noteRevoked(room, channel, body.subscriptionId, typeof body.status === "string" ? body.status.slice(0, 64) : ""); + return reply({ ok: true }); + } + return refuse(400, "invalid_body"); + } + return refuse(404, "not_found"); +} diff --git a/server/tracker.ts b/server/tracker.ts index e05bb6d..9f54389 100644 --- a/server/tracker.ts +++ b/server/tracker.ts @@ -6,7 +6,7 @@ import type { JsonObject } from "./parse.ts"; type Room = { ctx: DurableObjectState }; /** Every source the room log stores; the owner page filters by these. */ -export const LOG_SOURCES = ["room", "worker", "command", "warn", "alarm", "duel", "chat"]; +export const LOG_SOURCES = ["room", "worker", "command", "warn", "alarm", "duel", "chat", "points"]; /** Rows kept per source in error_log. Busy trace sources keep a shorter tail. */ export const KEEP_ROWS: { [source: string]: number } = { alarm: 200, duel: 200, chat: 200 }; const KEEP_DEFAULT = 500; diff --git a/server/worker.ts b/server/worker.ts index 9239dac..d1728f8 100644 --- a/server/worker.ts +++ b/server/worker.ts @@ -17,6 +17,15 @@ import { backupChannels } from "./backups.ts"; import { handleUploads } from "./uploads.ts"; import { handlePets } from "./pets.ts"; import { handleSprites } from "./sprites.ts"; +import { + createReward, + deleteReward, + markRedemption, + pointsLoginUrl, + rewardProblem, + MAX_POINT_REWARDS, +} from "./points.ts"; +import type { PointReward } from "./points.ts"; import { EVENTSUB_PATH, BOT_LOGIN_URL, @@ -189,6 +198,52 @@ async function bodyJson(request: Request, limit = 2200000): Promise return value; } // Chat source lifecycle (owner or mod via /api/admin, owner via /api/dev). The room only records the outcome. +// Channel point rewards (server/points.ts): Twitch first, then the room. A reward the room refuses is removed again. +async function pointsAction( + env: Env, + url: URL, + channel: string, + action: "createPointReward" | "deletePointReward", + data: JsonObject, + actor: string, +): Promise { + const room = (path: string, body?: JsonObject) => + roomFetch( + null, + env, + channel, + path, + body ? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) } : {}, + ); + const { rewards } = await (await room("/points")).json<{ rewards: PointReward[] }>(); + if (action === "deletePointReward") { + const reward = rewards.find((r) => r.rewardId === data.rewardId); + if (!reward) return json({ ok: false, reason: "reward_not_found", error: "reward_not_found" }, 404); + await deleteReward(env, { channel, rewardId: reward.rewardId, subscriptionId: reward.subscriptionId, url }); + return room("/points/rewards", { op: "remove", rewardId: reward.rewardId, actor }); + } + const { cost, dollars } = data, + title = typeof data.title === "string" ? data.title.trim() : data.title; + const problem = rewardProblem(title, cost, dollars); + if (problem) return json({ ok: false, reason: problem, error: problem }, 400); + if (rewards.length >= MAX_POINT_REWARDS) + return json({ ok: false, reason: "points_max_rewards", error: "points_max_rewards" }, 409); + const reward = await createReward(env, { + channel, + title: title as string, // rewardProblem checked these three + cost: cost as number, + dollars: dollars as number, + origin: siteOrigin(env, url, channel), + url, + }); + const r = await room("/points/rewards", { op: "add", reward, actor }); + if (!r.ok) + await deleteReward(env, { channel, rewardId: reward.rewardId, subscriptionId: reward.subscriptionId, url }).catch( + (e) => console.warn("point reward undo failed", e?.message), + ); + return r; +} + async function chatAction( env: Env, url: URL, @@ -500,6 +555,37 @@ async function handle(request: Request, env: Env, ctx: ExecutionContext | undefi ) .catch((e) => console.warn("bot reply report failed", e?.message)), ), + // A redemption: mark it on Twitch (FULFILLED, or CANCELED to refund), tell the room, then post the bot's line. + onRedeemed: ({ redemption: r, mark, reply }) => + ctx?.waitUntil?.( + (async () => { + if (mark) { + const status = await markRedemption(env, { + channel: r.channel, + rewardId: r.rewardId, + redemptionId: r.id, + status: mark, + }); + await roomFetch(null, env, r.channel, "/points/marked", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ redemptionId: r.id, status }), + }); + } + const botId = env.CHAT_BOT === "1" ? String((await record(env, "bot:twitch"))?.id || "") : ""; + if (!reply || !botId) return; + const results = await sendChatMessages(env, { + broadcasterId: r.broadcasterId, + senderId: botId, + messages: [reply], + }); + await roomFetch(null, env, r.channel, "/bot-sent", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ results }), + }); + })().catch((e) => console.warn("redemption follow-up failed", e?.message)), + ), }); } // StreamElements custom commands ($(customapi ...)): GET with the channel's key, answered with one chat line. @@ -720,6 +806,7 @@ async function handle(request: Request, env: Env, ctx: ExecutionContext | undefi chatBot: env.CHAT_BOT === "1" ? { login: bot?.login || "", debug: env.BOT_DEBUG === "1" } : null, modsReady, modsLapsed, + pointsLogin: pointsLoginUrl(channel), channelState: state, }); } @@ -743,7 +830,9 @@ async function handle(request: Request, env: Env, ctx: ExecutionContext | undefi ? "make a new StreamElements key" : data.takeover === true ? "move chat from another site" - : ""; + : data.action === "createPointReward" || data.action === "deletePointReward" + ? "change channel point rewards" + : ""; if (only && !roles.owner && !roles.broadcaster) return json({ error: "Only " + channel + " can " + only, reason: "broadcaster_only" }, 403); const admin = parseAdminBody(data); @@ -772,6 +861,8 @@ async function handle(request: Request, env: Env, ctx: ExecutionContext | undefi }); return json(await r.json(), r.status); } + if (action === "createPointReward" || action === "deletePointReward") + return await pointsAction(env, url, channel, action, admin.value.payload, user!.displayName || user!.login); // canManage: signed in if (action === "connectChat" && env.CHAT_BOT !== "1" && (channel !== site.defaultChannel || env.SE_ONLY === "1")) return json({ error: "This channel uses StreamElements for chat. Choose Use StreamElements." }, 400); if (action === "connectChat" || action === "disconnectChat" || action === "useStreamElements") diff --git a/tests/points.test.ts b/tests/points.test.ts new file mode 100644 index 0000000..a538469 --- /dev/null +++ b/tests/points.test.ts @@ -0,0 +1,371 @@ +// Channel point rewards (server/points.ts): signed redemptions through handleEventsub into a real room on node:sqlite, +// and the admin actions through the Worker with Twitch mocked. +import test from "node:test"; +import type { TestContext } from "node:test"; +import assert from "node:assert/strict"; +import { DatabaseSync } from "node:sqlite"; +import type { SQLInputValue } from "node:sqlite"; +import worker from "../server/worker.ts"; +import { AuthStore, digest, record, seal, handleAuth, POINTS_SCOPE } from "../server/auth.ts"; +import { forgetChannel } from "../server/channels.ts"; +import { eventsubSecret, handleEventsub, signEventsub } from "../server/eventsub.ts"; +import { ensurePointsSchema, handleRoomPoints, REDEMPTION_TYPE } from "../server/points.ts"; +import type { Redemption } from "../server/points.ts"; +import { ensureDeveloperSchema } from "../server/developer.ts"; +import { ensureTrackerSchema, readCounts } from "../server/tracker.ts"; +import type { Env } from "../server/env.ts"; +import { asEnv, fakeCtx, type FakeSql } from "./fakes.ts"; + +const ORIGIN = "https://staging.pixfray.xyz", + NOW = 1_800_000_000_000; + +function pointsRoom() { + const ctx = fakeCtx(), + sql = ctx.storage.sql; + sql.exec("CREATE TABLE profiles (user_id TEXT PRIMARY KEY, username TEXT NOT NULL, dollars INTEGER NOT NULL)"); + ensureDeveloperSchema(sql); + ensureTrackerSchema(sql); + ensurePointsSchema(sql); + const room = { ctx }; + const call = (channel: string, path: string, init: RequestInit = {}) => + handleRoomPoints(room, new Request("https://room" + path, init), { path, channel, maxDollars: 1_000_000 }); + const wallet = (userId: string) => + sql.exec<{ dollars: number }>("SELECT dollars FROM profiles WHERE user_id = ?", userId).toArray()[0]?.dollars; + return { room, sql, call, wallet }; +} +const post = (body: object): RequestInit => ({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), +}); +const REWARD = { + rewardId: "rw1", + title: "PixFray $50", + cost: 500, + dollars: 50, + subscriptionId: "sub1", + status: "enabled", + createdAt: 1, +}; +const env = asEnv({ AUTH_SECRET: "test-only-auth-key", INTERNAL_SECRET: "test-only-internal-key" }); + +function redemption(id: string, over: Record = {}) { + return { + id, + broadcaster_user_id: "55", + broadcaster_user_login: "newstreamer", + broadcaster_user_name: "NewStreamer", + user_id: "7", + user_login: "viewer", + user_name: "Viewer", + user_input: "", + status: "unfulfilled", + redeemed_at: "2027-01-15T08:00:00Z", + reward: { id: "rw1", title: "PixFray $50", cost: 500, prompt: "" }, + ...over, + }; +} +async function signed(type: string, body: object) { + const raw = JSON.stringify(body), + id = "m-" + Math.random(), + timestamp = new Date(NOW).toISOString(); + return new Request(ORIGIN + "/api/eventsub", { + method: "POST", + headers: { + "Twitch-Eventsub-Message-Id": id, + "Twitch-Eventsub-Message-Timestamp": timestamp, + "Twitch-Eventsub-Message-Signature": await signEventsub(await eventsubSecret(env), id, timestamp, raw), + "Twitch-Eventsub-Message-Type": type, + "Twitch-Eventsub-Subscription-Type": REDEMPTION_TYPE, + }, + body: raw, + }); +} +const subscription = { id: "sub1", status: "enabled", condition: { broadcaster_user_id: "55", reward_id: "rw1" } }; + +function eventsub(r: ReturnType) { + const done: { redemption: Redemption; mark: string | null; reply: string }[] = [], + paths: string[] = []; + const deliver = async (type: string, body: object) => + handleEventsub(await signed(type, body), env, { + channels: ["nesszers"], + roomFetch: (channel, path, init) => { + paths.push(channel + " " + path); + return r.call(channel, path, init); + }, + origin: () => ORIGIN, + findChannel: async (id) => (id === "55" ? "newstreamer" : ""), + onRedeemed: (d) => done.push(d), + now: NOW, + }); + return { done, paths, deliver }; +} + +test("a redemption adds the reward's dollars once, even when Twitch retries it", async () => { + const r = pointsRoom(), + e = eventsub(r); + r.sql.exec("INSERT INTO profiles VALUES ('7', 'viewer', 100)"); + assert.equal((await r.call("newstreamer", "/points/rewards", post({ op: "add", reward: REWARD }))).status, 200); + const first = await e.deliver("notification", { subscription, event: redemption("red-1") }); + const retry = await e.deliver("notification", { subscription, event: redemption("red-1") }); + assert.deepEqual([first.status, retry.status], [204, 204]); + assert.equal(r.wallet("7"), 150); + assert.equal(e.done.length, 1, "the retry marks nothing and posts nothing"); + assert.equal(e.done[0]?.mark, "FULFILLED"); + assert.equal(e.done[0]?.reply, "@Viewer got $50 PixFray dollars from PixFray $50. Wallet: $150."); + assert.equal(readCounts(r.room)[0]?.counts.points_granted, 1); +}); + +test("a reward PixFray doesn't hold is ignored: no dollars, no mark", async () => { + const r = pointsRoom(), + e = eventsub(r); + r.sql.exec("INSERT INTO profiles VALUES ('7', 'viewer', 100)"); + const res = await e.deliver("notification", { + subscription, + event: redemption("red-2", { reward: { id: "other", title: "Hydrate", cost: 100 } }), + }); + assert.equal(res.status, 204); + assert.equal(r.wallet("7"), 100); + assert.deepEqual(e.done, []); +}); + +test("a viewer with no fighter gets their points back and a line that differs per redemption", async () => { + const r = pointsRoom(), + e = eventsub(r); + await r.call("newstreamer", "/points/rewards", post({ op: "add", reward: REWARD })); + await e.deliver("notification", { subscription, event: redemption("red-aaaa") }); + await e.deliver("notification", { subscription, event: redemption("red-bbbb") }); + assert.deepEqual( + e.done.map((d) => [d.mark, d.reply]), + [ + [ + "CANCELED", + `@Viewer needs a saved fighter first: make one at ${ORIGIN}/?channel=newstreamer. Your channel points were refunded (redemption aaaa).`, + ], + [ + "CANCELED", + `@Viewer needs a saved fighter first: make one at ${ORIGIN}/?channel=newstreamer. Your channel points were refunded (redemption bbbb).`, + ], + ], + ); +}); + +test("a wallet never passes the dollar cap", async () => { + const r = pointsRoom(), + e = eventsub(r); + r.sql.exec("INSERT INTO profiles VALUES ('7', 'viewer', 999990)"); + await r.call("newstreamer", "/points/rewards", post({ op: "add", reward: REWARD })); + await e.deliver("notification", { subscription, event: redemption("red-3") }); + assert.equal(r.wallet("7"), 1_000_000); +}); + +test("the redemption subscription's challenge is answered, and a revocation marks the reward", async () => { + const r = pointsRoom(), + e = eventsub(r); + await r.call("newstreamer", "/points/rewards", post({ op: "add", reward: REWARD })); + const verify = await e.deliver("webhook_callback_verification", { subscription, challenge: "abc123" }); + assert.equal(await verify.text(), "abc123"); + const revoked = await e.deliver("revocation", { + subscription: { ...subscription, status: "authorization_revoked" }, + }); + assert.equal(revoked.status, 204); + assert.ok(e.paths.includes("newstreamer /points/rewards"), "the broadcaster's own room hears the revocation"); + const view = await (await r.call("newstreamer", "/points")).json<{ rewards: { status: string }[] }>(); + assert.equal(view.rewards[0]?.status, "authorization_revoked"); +}); + +test("a room failure fails the delivery, so Twitch retries it", async () => { + await assert.rejects( + handleEventsub(await signed("notification", { subscription, event: redemption("red-4") }), env, { + channels: [], + roomFetch: async () => new Response("down", { status: 500 }), + now: NOW, + }), + /Room rejected redemption \(500\)/, + ); +}); + +// ---------- Admin actions through the Worker ---------- + +function store(): AuthStore { + const db = new DatabaseSync(":memory:"); + const sql: FakeSql = { + exec(q: string, ...p: SQLInputValue[]) { + const rows = db + .prepare(q) + .all(...p) + .map((x) => ({ ...x })); + return { toArray: () => rows, [Symbol.iterator]: () => rows[Symbol.iterator]() }; + }, + }; + let alarm: number | null = null; + const ctx = { + storage: { + sql, + getAlarm: async () => alarm, + setAlarm: async (t: number) => void (alarm = t), + }, + }; + // the fake context has only the sql and alarm calls AuthStore makes + return new AuthStore(ctx as unknown as DurableObjectState, asEnv({ INTERNAL_SECRET: "test-only-internal-key" })); +} +async function site(t: TestContext, { scopes = ["moderation:read", POINTS_SCOPE] } = {}) { + forgetChannel(); + t.mock.method(Date, "now", () => NOW); + const auth = store(), + r = pointsRoom(); + const e = { + AUTH_SECRET: "test-only-auth-key", + INTERNAL_SECRET: "test-only-internal-key", + OWNER_TWITCH_ID: "1", + TWITCH_CLIENT_ID: "test-app", + TWITCH_CLIENT_SECRET: "test-secret", + PUBLIC_ORIGIN: ORIGIN, + AUTH: { + idFromName: (x: string) => x, + get: () => ({ fetch: (u: string, i?: RequestInit) => auth.fetch(new Request(u, i)) }), + }, + ROOMS: { + idFromName: (x: string) => x, + get: (channel: string) => ({ + async fetch(url: string, init?: RequestInit) { + const path = new URL(url).pathname; + return path.startsWith("/points") ? r.call(channel, path, init) : Response.json({}); + }, + }), + }, + ASSETS: { fetch: async () => new Response("page") }, + }; + const env = e as typeof e & Env; // a partial Env: the bindings worker.fetch reads + await record(env, "channel:newstreamer", { id: "55", login: "newstreamer", enabledAt: 1 }, NOW + 86400000); + await record( + env, + "broadcaster:newstreamer", + await seal(env, { access_token: "a1", refresh_token: "r1", userId: "55", scopes, validatedAt: NOW }), + NOW + 86400000, + ); + const cookie = async (id: string, login: string) => { + const key = id.padStart(64, "c"); + await record(env, "session:" + (await digest(key)), { user: { id, login, displayName: login } }, NOW + 3600000); + return "mini_session=" + key; + }; + const admin = (data: object, c: string) => + worker.fetch( + new Request(ORIGIN + "/api/admin/newstreamer", { + method: "POST", + headers: { Cookie: c, Origin: ORIGIN, "Content-Type": "application/json" }, + body: JSON.stringify(data), + }), + env, + ); + return { env, r, cookie, admin }; +} +const CREATE = { action: "createPointReward", payload: { title: "PixFray $50", cost: 500, dollars: 50 } }; + +test("a channel that isn't an Affiliate hears so in plain words when it makes a reward", async (t) => { + const s = await site(t); + t.mock.method(globalThis, "fetch", async (url: string | URL | Request) => { + if (String(url).includes("/helix/channel_points/custom_rewards")) return new Response("forbidden", { status: 403 }); + throw new Error("unexpected fetch " + url); + }); + const res = await s.admin(CREATE, await s.cookie("55", "newstreamer")); + assert.equal(res.status, 403); + assert.deepEqual(await res.json(), { + error: "Twitch only gives channel points to Affiliates and Partners, and newstreamer is neither yet.", + reason: "points_not_affiliate", + }); +}); + +test("rewards need the broadcaster's points sign-in, and only the broadcaster or the owner can make one", async (t) => { + const s = await site(t, { scopes: ["moderation:read"] }); + t.mock.method(globalThis, "fetch", async (url: string | URL | Request) => { + if (String(url).includes("/helix/moderation/moderators")) return Response.json({ data: [{ user_id: "9" }] }); + throw new Error("unexpected fetch " + url); + }); + const mod = await s.admin(CREATE, await s.cookie("9", "amod")); + assert.equal(mod.status, 403); + assert.equal((await mod.json<{ reason: string }>()).reason, "broadcaster_only"); + const res = await s.admin(CREATE, await s.cookie("55", "newstreamer")); + assert.equal(res.status, 409); + assert.deepEqual(await res.json(), { + error: "newstreamer must allow channel point rewards at /auth/login?channel=newstreamer&connect=points", + reason: "points_not_connected", + reconnect: "/auth/login?channel=newstreamer&connect=points", + }); +}); + +test("a new reward is subscribed to, and removed again when Twitch refuses the subscription", async (t) => { + const s = await site(t), + calls: string[] = []; + let subscribe = 202; + t.mock.method( + globalThis, + "fetch", + async (url: string | URL | Request, init: { method?: string; body?: string } = {}) => { + const u = String(url).replace("https://api.twitch.tv/helix", ""); + calls.push((init.method || "GET") + " " + u.split("?")[0]); + if (u.startsWith("/channel_points/custom_rewards") && init.method === "POST") { + assert.deepEqual(JSON.parse(String(init.body)), { + title: "PixFray $50", + cost: 500, + prompt: "Adds $50 PixFray dollars to your fighter. Save a fighter first, or your points come back.", + should_redemptions_skip_request_queue: false, + }); + return Response.json({ data: [{ id: "rw-new" }] }); + } + if (u.startsWith("/channel_points/custom_rewards") && init.method === "DELETE") + return new Response(null, { status: 204 }); + if (u.endsWith("/oauth2/token")) return Response.json({ access_token: "app", expires_in: 3600 }); + if (u.startsWith("/eventsub/subscriptions")) { + const body = JSON.parse(String(init.body)); + assert.deepEqual( + [body.type, body.condition, body.transport.callback], + [REDEMPTION_TYPE, { broadcaster_user_id: "55", reward_id: "rw-new" }, ORIGIN + "/api/eventsub"], + ); + return subscribe === 202 + ? Response.json( + { data: [{ id: "sub-new", status: "webhook_callback_verification_pending" }] }, + { status: 202 }, + ) + : new Response("no", { status: subscribe }); + } + throw new Error("unexpected fetch " + u); + }, + ); + const streamer = await s.cookie("55", "newstreamer"); + const made = await s.admin(CREATE, streamer); + assert.equal(made.status, 200); + assert.deepEqual( + (await made.json<{ rewards: { rewardId: string; subscriptionId: string }[] }>()).rewards.map((x) => [ + x.rewardId, + x.subscriptionId, + ]), + [["rw-new", "sub-new"]], + ); + subscribe = 409; + calls.length = 0; + const refused = await s.admin(CREATE, streamer); + assert.equal(refused.status, 502); + assert.ok(calls.includes("DELETE /channel_points/custom_rewards"), "the orphan reward is deleted"); +}); + +test("the points sign-in asks for the earlier scopes plus channel:manage:redemptions", async () => { + forgetChannel(); + const auth = store(); + const e = { + AUTH_SECRET: "test-only-auth-key", + INTERNAL_SECRET: "test-only-internal-key", + TWITCH_CLIENT_ID: "test-app", + TWITCH_CLIENT_SECRET: "test-secret", + CHAT_BOT: "1", + AUTH: { + idFromName: (x: string) => x, + get: () => ({ fetch: (u: string, i?: RequestInit) => auth.fetch(new Request(u, i)) }), + }, + }; + const env = e as typeof e & Env; // the bindings handleAuth reads + await record(env, "channel:newstreamer", { id: "55", login: "newstreamer", enabledAt: 1 }, Date.now() + 86400000); + const res = await handleAuth(new Request(ORIGIN + "/auth/login?channel=newstreamer&connect=points"), env); + const to = new URL(String(res.headers.get("Location"))); + assert.equal(to.searchParams.get("scope"), "moderation:read channel:bot channel:manage:redemptions"); +}); diff --git a/tsconfig.node.json b/tsconfig.node.json index 3d8c284..3bf8ed1 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -27,6 +27,7 @@ "tests/game.test.ts", "tests/hardening.test.ts", "tests/hosts.test.ts", + "tests/points.test.ts", "tests/registry.test.ts", "tests/security.test.ts", "tests/site-config.test.ts",