From 3492f409882eb85fce2f52e4ea1da0d07f037463 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 24 Sep 2026 17:21:03 -0700 Subject: [PATCH 1/2] Keep Twitch markers private to owners and editors --- src/app/(core)/v/[slug]/page.tsx | 21 +++++++-- src/utils/twitch-server.test.ts | 81 ++++++++++++++++++++++++++++++++ src/utils/twitch-server.ts | 47 ++++++++++-------- 3 files changed, 124 insertions(+), 25 deletions(-) create mode 100644 src/utils/twitch-server.test.ts diff --git a/src/app/(core)/v/[slug]/page.tsx b/src/app/(core)/v/[slug]/page.tsx index a5c2bfd..b05a068 100644 --- a/src/app/(core)/v/[slug]/page.tsx +++ b/src/app/(core)/v/[slug]/page.tsx @@ -2,16 +2,13 @@ import { auth } from "@clerk/nextjs/server"; import { getTwitchTokenFromClerk, getVodWithMarkers, + TwitchMarkerAccessDeniedError, } from "~/utils/twitch-server"; import { VodPlayer } from "./player"; import Script from "next/script"; export const dynamic = "force-dynamic"; -// I do the revalidate 0 here because "force-dynamic" doesn't actually work -// See: https://github.com/vercel/next.js/issues/47273 -export const revalidate = 60; - export default async function VodPage({ params, }: { @@ -23,7 +20,21 @@ export default async function VodPage({ const token = await getTwitchTokenFromClerk(self.userId); - const vodDetails = await getVodWithMarkers(slug, token); + let vodDetails; + try { + vodDetails = await getVodWithMarkers(slug, token); + } catch (error) { + if (!(error instanceof TwitchMarkerAccessDeniedError)) throw error; + return ( +
+

Markers are private

+

+ Sign in with the Twitch account that owns this VOD or has editor + access. +

+
+ ); + } return ( <> diff --git a/src/utils/twitch-server.test.ts b/src/utils/twitch-server.test.ts new file mode 100644 index 0000000..19c169a --- /dev/null +++ b/src/utils/twitch-server.test.ts @@ -0,0 +1,81 @@ +import { clerkClient } from "@clerk/nextjs/server"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + getVodWithMarkers, + TwitchMarkerAccessDeniedError, +} from "./twitch-server"; + +vi.mock("@clerk/nextjs/server", () => ({ clerkClient: vi.fn() })); + +const vod = { created_at: "2026-09-24T00:00:00Z", duration: "1h0m0s" }; +const videoResponse = () => + new Response(JSON.stringify({ data: [vod] }), { status: 200 }); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe("getVodWithMarkers", () => { + it("uses the viewer's token for private markers", async () => { + const markers = [ + { + id: "marker-1", + created_at: "2026-09-24T00:05:00Z", + description: "Topic", + position_seconds: 300, + URL: "https://twitch.tv/example", + }, + ]; + const fetchMock = vi + .fn() + .mockResolvedValueOnce(videoResponse()) + .mockResolvedValueOnce( + new Response(JSON.stringify({ data: [{ videos: [{ markers }] }] }), { + status: 200, + }) + ); + vi.stubGlobal("fetch", fetchMock); + + await expect(getVodWithMarkers("123", "viewer-token")).resolves.toEqual({ + ...vod, + markers, + }); + const markerRequest = fetchMock.mock.calls[1]![1] as RequestInit; + expect(new Headers(markerRequest.headers).get("Authorization")).toBe( + "Bearer viewer-token" + ); + expect(markerRequest.cache).toBe("no-store"); + expect(clerkClient).not.toHaveBeenCalled(); + }); + + it("denies access when Twitch rejects the viewer", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(videoResponse()) + .mockResolvedValueOnce(new Response(null, { status: 403 })); + vi.stubGlobal("fetch", fetchMock); + + await expect( + getVodWithMarkers("123", "viewer-token") + ).rejects.toBeInstanceOf(TwitchMarkerAccessDeniedError); + expect(clerkClient).not.toHaveBeenCalled(); + }); + + it.each([401, 500])( + "does not turn Twitch %i into empty markers", + async (status) => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce(videoResponse()) + .mockResolvedValueOnce(new Response(null, { status })) + ); + + await expect(getVodWithMarkers("123", "viewer-token")).rejects.toThrow( + `Could not load markers: Twitch returned ${status}` + ); + } + ); +}); diff --git a/src/utils/twitch-server.ts b/src/utils/twitch-server.ts index 5fb5577..7457de6 100644 --- a/src/utils/twitch-server.ts +++ b/src/utils/twitch-server.ts @@ -49,8 +49,6 @@ const getValidTokenForCreator = async (creatorName: string) => { const creatorFoundInClerk = response.data[0]; - console.log("found in clerk?", creatorFoundInClerk); - // Early escape if we don't find this user in Clerk if (!creatorFoundInClerk) { throw new Error("User not found in Clerk"); @@ -59,6 +57,12 @@ const getValidTokenForCreator = async (creatorName: string) => { return await getTwitchTokenFromClerk(creatorFoundInClerk.id); }; +export class TwitchMarkerAccessDeniedError extends Error { + constructor() { + super("Only the broadcaster or a Twitch editor can view these markers"); + } +} + export const getVodWithMarkers = async (vodId: string, token: string) => { const vodResponse = await fetch( `https://api.twitch.tv/helix/videos?id=${vodId}`, @@ -69,34 +73,37 @@ export const getVodWithMarkers = async (vodId: string, token: string) => { cache: "no-store", } ); - console.log("VOD RESPONSE", vodResponse.status); - - const vodData = await vodResponse.json(); - console.log("VOD DATA", vodData); - - const creatorName = vodData?.data?.[0]?.user_login; - - if (!creatorName) throw new Error("could not find vod data or user login"); + if (!vodResponse.ok) { + throw new Error( + `Could not load VOD: Twitch returned ${vodResponse.status}` + ); + } - const tokenForMarkers = await getValidTokenForCreator(creatorName); + const vodData = (await vodResponse.json()) as { + data?: Omit[]; + }; + const vod = vodData.data?.[0]; + if (!vod) throw new Error("VOD not found"); const markersResponse = await fetch( `https://api.twitch.tv/helix/streams/markers?video_id=${vodId}&first=100`, { method: "GET", - headers: generateTwitchRequestHeaders(tokenForMarkers), - next: { revalidate: 60 }, + headers: generateTwitchRequestHeaders(token), + cache: "no-store", } ); + if (markersResponse.status === 403) throw new TwitchMarkerAccessDeniedError(); + if (!markersResponse.ok) { + throw new Error( + `Could not load markers: Twitch returned ${markersResponse.status}` + ); + } - console.log("MARKER RESPONSE", markersResponse.status); - - const markersData = await markersResponse.json(); - console.log("MARKER DATA", markersData); - - const markers = markersData?.data?.[0]?.videos?.[0]["markers"] ?? []; + const markersData = (await markersResponse.json()) as TwitchMarkersResponse; + const markers = markersData.data?.[0]?.videos?.[0]?.markers ?? []; - return { ...vodData?.data?.[0], markers } as VOD; + return { ...vod, markers }; }; export const getTwitchTokenFromClerk = async (clerkUserId: string) => { From 6b47ebdbe2e3d3ef3b54e3a3ab652f0041a3266c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 24 Sep 2026 21:08:29 -0700 Subject: [PATCH 2/2] Keep creator-token marker access and fix loader errors and logging The VOD page reads markers with the creator's stored token again, as on main, and the page keeps its 60 second revalidate. Removes the viewer token path and the "markers are private" page. Keeps the clear fixes: no more logs of the Clerk user or Twitch payloads, non-OK Twitch responses throw, and an empty videos list gives no markers. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/(core)/v/[slug]/page.tsx | 21 ++---- src/utils/twitch-server.test.ts | 108 ++++++++++++------------------- src/utils/twitch-server.ts | 31 ++++----- 3 files changed, 59 insertions(+), 101 deletions(-) diff --git a/src/app/(core)/v/[slug]/page.tsx b/src/app/(core)/v/[slug]/page.tsx index b05a068..a5c2bfd 100644 --- a/src/app/(core)/v/[slug]/page.tsx +++ b/src/app/(core)/v/[slug]/page.tsx @@ -2,13 +2,16 @@ import { auth } from "@clerk/nextjs/server"; import { getTwitchTokenFromClerk, getVodWithMarkers, - TwitchMarkerAccessDeniedError, } from "~/utils/twitch-server"; import { VodPlayer } from "./player"; import Script from "next/script"; export const dynamic = "force-dynamic"; +// I do the revalidate 0 here because "force-dynamic" doesn't actually work +// See: https://github.com/vercel/next.js/issues/47273 +export const revalidate = 60; + export default async function VodPage({ params, }: { @@ -20,21 +23,7 @@ export default async function VodPage({ const token = await getTwitchTokenFromClerk(self.userId); - let vodDetails; - try { - vodDetails = await getVodWithMarkers(slug, token); - } catch (error) { - if (!(error instanceof TwitchMarkerAccessDeniedError)) throw error; - return ( -
-

Markers are private

-

- Sign in with the Twitch account that owns this VOD or has editor - access. -

-
- ); - } + const vodDetails = await getVodWithMarkers(slug, token); return ( <> diff --git a/src/utils/twitch-server.test.ts b/src/utils/twitch-server.test.ts index 19c169a..7333644 100644 --- a/src/utils/twitch-server.test.ts +++ b/src/utils/twitch-server.test.ts @@ -1,81 +1,55 @@ -import { clerkClient } from "@clerk/nextjs/server"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { - getVodWithMarkers, - TwitchMarkerAccessDeniedError, -} from "./twitch-server"; - -vi.mock("@clerk/nextjs/server", () => ({ clerkClient: vi.fn() })); - -const vod = { created_at: "2026-09-24T00:00:00Z", duration: "1h0m0s" }; -const videoResponse = () => - new Response(JSON.stringify({ data: [vod] }), { status: 200 }); +import { getVodWithMarkers } from "./twitch-server"; + +// The creator has signed in before, so Clerk has their Twitch token +vi.mock("@clerk/nextjs/server", () => ({ + clerkClient: async () => ({ + users: { + getUserList: async () => ({ data: [{ id: "creator" }] }), + getUserOauthAccessToken: async () => ({ + data: [{ token: "creator-token" }], + }), + }, + }), +})); + +const vod = { + created_at: "2026-09-24T00:00:00Z", + duration: "1h0m0s", + user_login: "creator", +}; + +// Stubs the videos request, then the markers request +const stubTwitch = (markersResponse: Response) => + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce(Response.json({ data: [vod] })) + .mockResolvedValueOnce(markersResponse) + ); afterEach(() => { vi.unstubAllGlobals(); - vi.clearAllMocks(); }); describe("getVodWithMarkers", () => { - it("uses the viewer's token for private markers", async () => { - const markers = [ - { - id: "marker-1", - created_at: "2026-09-24T00:05:00Z", - description: "Topic", - position_seconds: 300, - URL: "https://twitch.tv/example", - }, - ]; - const fetchMock = vi - .fn() - .mockResolvedValueOnce(videoResponse()) - .mockResolvedValueOnce( - new Response(JSON.stringify({ data: [{ videos: [{ markers }] }] }), { - status: 200, - }) - ); - vi.stubGlobal("fetch", fetchMock); + it("throws when the markers request fails", async () => { + stubTwitch( + Response.json({ error: "Unauthorized", status: 401 }, { status: 401 }) + ); - await expect(getVodWithMarkers("123", "viewer-token")).resolves.toEqual({ - ...vod, - markers, - }); - const markerRequest = fetchMock.mock.calls[1]![1] as RequestInit; - expect(new Headers(markerRequest.headers).get("Authorization")).toBe( - "Bearer viewer-token" + await expect(getVodWithMarkers("123", "viewer-token")).rejects.toThrow( + "Twitch markers request failed: 401" ); - expect(markerRequest.cache).toBe("no-store"); - expect(clerkClient).not.toHaveBeenCalled(); }); - it("denies access when Twitch rejects the viewer", async () => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce(videoResponse()) - .mockResolvedValueOnce(new Response(null, { status: 403 })); - vi.stubGlobal("fetch", fetchMock); + it("gives no markers when Twitch sends an empty videos list", async () => { + stubTwitch(Response.json({ data: [{ videos: [] }] })); - await expect( - getVodWithMarkers("123", "viewer-token") - ).rejects.toBeInstanceOf(TwitchMarkerAccessDeniedError); - expect(clerkClient).not.toHaveBeenCalled(); + await expect(getVodWithMarkers("123", "viewer-token")).resolves.toEqual({ + ...vod, + markers: [], + }); }); - - it.each([401, 500])( - "does not turn Twitch %i into empty markers", - async (status) => { - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValueOnce(videoResponse()) - .mockResolvedValueOnce(new Response(null, { status })) - ); - - await expect(getVodWithMarkers("123", "viewer-token")).rejects.toThrow( - `Could not load markers: Twitch returned ${status}` - ); - } - ); }); diff --git a/src/utils/twitch-server.ts b/src/utils/twitch-server.ts index 7457de6..d91ad7c 100644 --- a/src/utils/twitch-server.ts +++ b/src/utils/twitch-server.ts @@ -39,7 +39,7 @@ export type VOD = { duration: string; }; -// Used for vod markers +// Used for VOD markers and the live topic embed const getValidTokenForCreator = async (creatorName: string) => { // Get token for the input displayName IF THEY HAVE SIGNED IN BEFORE const clerk = await clerkClient(); @@ -57,12 +57,9 @@ const getValidTokenForCreator = async (creatorName: string) => { return await getTwitchTokenFromClerk(creatorFoundInClerk.id); }; -export class TwitchMarkerAccessDeniedError extends Error { - constructor() { - super("Only the broadcaster or a Twitch editor can view these markers"); - } -} - +// Loads a VOD and its markers. Markers are read with the creator's stored +// token, so any signed-in user can view a connected creator's markers. +// Throws on Twitch errors, so a failed request is not read as "no markers". export const getVodWithMarkers = async (vodId: string, token: string) => { const vodResponse = await fetch( `https://api.twitch.tv/helix/videos?id=${vodId}`, @@ -74,30 +71,28 @@ export const getVodWithMarkers = async (vodId: string, token: string) => { } ); if (!vodResponse.ok) { - throw new Error( - `Could not load VOD: Twitch returned ${vodResponse.status}` - ); + throw new Error(`Twitch videos request failed: ${vodResponse.status}`); } const vodData = (await vodResponse.json()) as { - data?: Omit[]; + data?: (Omit & { user_login: string })[]; }; const vod = vodData.data?.[0]; - if (!vod) throw new Error("VOD not found"); + + if (!vod) throw new Error("could not find vod data or user login"); + + const tokenForMarkers = await getValidTokenForCreator(vod.user_login); const markersResponse = await fetch( `https://api.twitch.tv/helix/streams/markers?video_id=${vodId}&first=100`, { method: "GET", - headers: generateTwitchRequestHeaders(token), - cache: "no-store", + headers: generateTwitchRequestHeaders(tokenForMarkers), + next: { revalidate: 60 }, } ); - if (markersResponse.status === 403) throw new TwitchMarkerAccessDeniedError(); if (!markersResponse.ok) { - throw new Error( - `Could not load markers: Twitch returned ${markersResponse.status}` - ); + throw new Error(`Twitch markers request failed: ${markersResponse.status}`); } const markersData = (await markersResponse.json()) as TwitchMarkersResponse;