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",