diff --git a/Dockerfile b/Dockerfile index 30417de..993caef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 RUN bun install --frozen-lockfile --production FROM mcr.microsoft.com/playwright:v1.62.1-noble AS runner -ARG BUILD_VERSION=1.5.1-dev +ARG BUILD_VERSION=1.6.0-dev ARG BUILD_REVISION=development ARG BUILD_TIME=unknown RUN apt-get update \ diff --git a/package.json b/package.json index 6913223..9a48aa8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "typetype-token", - "version": "1.5.1", + "version": "1.6.0", "private": true, "license": "MIT", "scripts": { diff --git a/src/index.ts b/src/index.ts index eb9466d..839c5a9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import type { Server } from "bun"; import { buildInfo } from "./build-info.ts"; +import { handlePoTokenRequest } from "./po-token-routes.ts"; import { readRemoteLoginConfig } from "./remote-login-config.ts"; import { RemoteLoginManager } from "./remote-login-manager.ts"; import { @@ -8,7 +9,6 @@ import { type RemoteLoginWebSocketData, } from "./remote-login-routes.ts"; import { handleSubtitleRequest } from "./subtitle-routes.ts"; -import { fetchPoToken } from "./token-service.ts"; import { decodeYoutubePlayerBatch } from "./youtube-player-decoder.ts"; import { fetchYoutubeSabrSession } from "./youtube-sabr-session.ts"; import type { YoutubeSabrClient } from "./youtube-sabr-types.ts"; @@ -27,26 +27,8 @@ export async function handler( if (remoteLoginResponse !== null) return remoteLoginResponse; const subtitleResponse = await handleSubtitleRequest(req, url); if (subtitleResponse !== null) return subtitleResponse; - - if (req.method === "GET" && url.pathname === "/potoken") { - const videoId = url.searchParams.get("videoId"); - - if (!videoId) { - return Response.json({ error: "videoId query parameter is required" }, { status: 400 }); - } - - try { - const result = await fetchPoToken( - videoId, - url.searchParams.get("refresh") === "true", - url.searchParams.get("refreshVideo") === "true", - ); - return Response.json(result); - } catch (error) { - const message = error instanceof Error ? error.message : "Internal error"; - return Response.json({ error: message }, { status: 500 }); - } - } + const poTokenResponse = await handlePoTokenRequest(req, url); + if (poTokenResponse !== null) return poTokenResponse; if (req.method === "GET" && url.pathname === "/youtube/sabr/session") { const videoId = url.searchParams.get("videoId"); diff --git a/src/po-token-routes.ts b/src/po-token-routes.ts new file mode 100644 index 0000000..cd85601 --- /dev/null +++ b/src/po-token-routes.ts @@ -0,0 +1,46 @@ +import { fetchPoToken, fetchSessionPoTokens } from "./token-service.ts"; + +export async function handlePoTokenRequest(req: Request, url: URL): Promise { + if (req.method === "GET" && url.pathname === "/potoken") { + const videoId = url.searchParams.get("videoId"); + if (!videoId) { + return Response.json({ error: "videoId query parameter is required" }, { status: 400 }); + } + return tokenResponse(() => + fetchPoToken( + videoId, + url.searchParams.get("refresh") === "true", + url.searchParams.get("refreshVideo") === "true", + ), + ); + } + if (req.method !== "POST" || url.pathname !== "/potoken/session") return null; + const body = (await req.json().catch(() => null)) as { + videoId?: unknown; + sessionBinding?: unknown; + refreshVideo?: unknown; + } | null; + const videoId = normalizedString(body?.videoId, 128); + const sessionBinding = normalizedString(body?.sessionBinding, 4096); + if (!videoId || !sessionBinding) { + return Response.json({ error: "videoId and sessionBinding are required" }, { status: 400 }); + } + return tokenResponse(() => + fetchSessionPoTokens(videoId, sessionBinding, body?.refreshVideo === true), + ); +} + +function normalizedString(value: unknown, maxLength: number): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim(); + return normalized && normalized.length <= maxLength ? normalized : null; +} + +async function tokenResponse(load: () => Promise): Promise { + try { + return Response.json(await load()); + } catch (error) { + const message = error instanceof Error ? error.message : "Internal error"; + return Response.json({ error: message }, { status: 500 }); + } +} diff --git a/src/remote-login-browser.ts b/src/remote-login-browser.ts index 52072b7..c2e63be 100644 --- a/src/remote-login-browser.ts +++ b/src/remote-login-browser.ts @@ -44,6 +44,7 @@ export type RemoteLoginPage = { }; close: () => Promise; cookies: () => Promise; + authUser: () => Promise; hasLoginCookie: () => Promise; }; @@ -145,6 +146,16 @@ export async function createRemoteLoginPage( close: () => context.close().catch(() => undefined), cookies: async () => formatNetscapeCookies(await context.cookies(COOKIE_URLS), config.maxCookieBytes), + authUser: async () => + page.evaluate(() => { + const ytcfg = ( + globalThis as typeof globalThis & { + ytcfg?: { get: (key: string) => unknown }; + } + ).ytcfg; + const value = Number(ytcfg?.get("SESSION_INDEX") ?? 0); + return Number.isInteger(value) && value >= 0 && value <= 99 ? value : 0; + }), hasLoginCookie: async () => (await context.cookies(COOKIE_URLS)).some(isLoginCookie), }; } diff --git a/src/remote-login-callback.ts b/src/remote-login-callback.ts index 2f2a8a0..fbef401 100644 --- a/src/remote-login-callback.ts +++ b/src/remote-login-callback.ts @@ -11,6 +11,7 @@ type RemoteLoginCompletionPayload = { status: "completed"; cookies: string; poToken: string; + authUser: number; capturedAt: number; }; @@ -19,6 +20,7 @@ export async function sendRemoteLoginCompletion( tokenSessionId: string, cookies: string, poToken: string, + authUser: number, config: RemoteLoginConfig, ): Promise { if (!config.internalToken) return false; @@ -30,6 +32,7 @@ export async function sendRemoteLoginCompletion( status: "completed", cookies, poToken, + authUser, capturedAt: Date.now(), }; diff --git a/src/remote-login-session.ts b/src/remote-login-session.ts index cec54e0..75455dc 100644 --- a/src/remote-login-session.ts +++ b/src/remote-login-session.ts @@ -110,11 +110,13 @@ export class RemoteLoginSession { const poToken = await this.poToken.wait(this.config.potTimeoutMs); if (!poToken || this.closed) return this.fail("PO token capture timed out"); const cookies = await this.page.cookies(); + const authUser = await this.page.authUser(); const sent = await sendRemoteLoginCompletion( this.target, this.sessionId, cookies, poToken, + authUser, this.config, ); if (!sent || this.closed) return this.fail("Completion callback failed"); diff --git a/src/token-service.ts b/src/token-service.ts index c0b12e1..37559ec 100644 --- a/src/token-service.ts +++ b/src/token-service.ts @@ -13,6 +13,10 @@ export type TokenResult = { streamingPot: string; }; +export type SessionTokenResult = TokenResult & { + sessionBoundPoToken: string; +}; + type CachedSession = { visitorData: string; visitorBoundPoToken: string; @@ -118,10 +122,32 @@ export async function fetchPoToken( refreshVideo = false, ): Promise { const currentSession = await getOrRefreshSession(forceRefresh); - const { visitorData, visitorBoundPoToken } = currentSession; const videoBoundPoToken = refreshVideo ? await refreshVideoBoundPoToken(currentSession, videoId) : await getVideoBoundPoToken(currentSession, videoId); + return tokenResult(currentSession, videoBoundPoToken); +} + +export async function fetchSessionPoTokens( + videoId: string, + sessionBinding: string, + refreshVideo = false, +): Promise { + const currentSession = await getOrRefreshSession(); + const [videoBoundPoToken, sessionBoundPoToken] = await Promise.all([ + refreshVideo + ? refreshVideoBoundPoToken(currentSession, videoId) + : getVideoBoundPoToken(currentSession, videoId), + getVideoBoundPoToken(currentSession, sessionBinding), + ]); + return { + ...tokenResult(currentSession, videoBoundPoToken), + sessionBoundPoToken, + }; +} + +function tokenResult(currentSession: CachedSession, videoBoundPoToken: string): TokenResult { + const { visitorData, visitorBoundPoToken } = currentSession; return { visitorData, visitorBoundPoToken, diff --git a/tests/index.test.ts b/tests/index.test.ts index 1e18a04..5f83803 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -52,6 +52,10 @@ const mockFetchYoutubeSabrSession = mock( mock.module("../src/token-service.ts", () => ({ fetchPoToken: mockFetchPoToken, + fetchSessionPoTokens: mock(async () => ({ + ...(await mockFetchPoToken("abc")), + sessionBoundPoToken: "session-bound-token", + })), })); mock.module("../src/innertube.ts", () => ({ diff --git a/tests/po-token-routes.test.ts b/tests/po-token-routes.test.ts new file mode 100644 index 0000000..486d924 --- /dev/null +++ b/tests/po-token-routes.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, mock } from "bun:test"; +import type { SessionTokenResult } from "../src/token-service.ts"; + +const fetchSessionPoTokens = mock( + async ( + videoId: string, + sessionBinding: string, + _refreshVideo = false, + ): Promise => ({ + visitorData: "token-visitor", + visitorBoundPoToken: "visitor-token", + videoBoundPoToken: `video-${videoId}`, + poToken: "visitor-token", + streamingPot: `video-${videoId}`, + sessionBoundPoToken: `session-${sessionBinding}`, + }), +); + +mock.module("../src/token-service.ts", () => ({ + fetchPoToken: mock(async () => ({})), + fetchSessionPoTokens, +})); + +describe("POST /potoken/session", () => { + it("returns tokens bound by one internal request", async () => { + const { handlePoTokenRequest } = await import("../src/po-token-routes.ts?session-route-test"); + const request = new Request("http://localhost:8081/potoken/session", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ videoId: "kids", sessionBinding: "account-visitor" }), + }); + const response = await handlePoTokenRequest(request, new URL(request.url)); + + expect(response?.status).toBe(200); + expect(await response?.json()).toMatchObject({ + videoBoundPoToken: "video-kids", + sessionBoundPoToken: "session-account-visitor", + }); + expect(fetchSessionPoTokens).toHaveBeenCalledWith("kids", "account-visitor", false); + }); + + it("rejects incomplete and oversized bindings", async () => { + const { handlePoTokenRequest } = await import("../src/po-token-routes.ts?session-route-test"); + const callsBefore = fetchSessionPoTokens.mock.calls.length; + const request = new Request("http://localhost:8081/potoken/session", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ videoId: "kids", sessionBinding: "x".repeat(4097) }), + }); + const response = await handlePoTokenRequest(request, new URL(request.url)); + + expect(response?.status).toBe(400); + expect(fetchSessionPoTokens.mock.calls.length).toBe(callsBefore); + }); +}); diff --git a/tests/remote-login-callback.test.ts b/tests/remote-login-callback.test.ts index fa3b3f1..3f63e93 100644 --- a/tests/remote-login-callback.test.ts +++ b/tests/remote-login-callback.test.ts @@ -10,6 +10,7 @@ describe("sendRemoteLoginCompletion", () => { "token-session", "# Netscape HTTP Cookie File", "captured-pot", + 0, remoteLoginTestConfig({ internalToken: null }), ); @@ -18,7 +19,12 @@ describe("sendRemoteLoginCompletion", () => { it("posts the completion payload to TypeType-Server", async () => { let header: string | null = null; - let payload: { sessionId: string; tokenSessionId: string; status: string } | null = null; + let payload: { + sessionId: string; + tokenSessionId: string; + status: string; + authUser: number; + } | null = null; const server = Bun.serve({ port: 0, fetch: async (req) => { @@ -39,6 +45,7 @@ describe("sendRemoteLoginCompletion", () => { "token-session", "# Netscape HTTP Cookie File", "captured-pot", + 2, config, ); @@ -47,6 +54,7 @@ describe("sendRemoteLoginCompletion", () => { expect(payload?.sessionId).toBe("server-session"); expect(payload?.tokenSessionId).toBe("token-session"); expect(payload?.status).toBe("completed"); + expect(payload?.authUser).toBe(2); } finally { server.stop(true); } diff --git a/tests/remote-login-fixtures.ts b/tests/remote-login-fixtures.ts index 08dfb5e..2ddd965 100644 --- a/tests/remote-login-fixtures.ts +++ b/tests/remote-login-fixtures.ts @@ -62,6 +62,7 @@ export function fakeRemoteLoginPage(overrides: Partial = {}): R }, close: async () => undefined, cookies: async () => "# Netscape HTTP Cookie File", + authUser: async () => 0, hasLoginCookie: async () => false, ...overrides, }; diff --git a/tests/subtitles.test.ts b/tests/subtitles.test.ts index 92adb1b..2fe840c 100644 --- a/tests/subtitles.test.ts +++ b/tests/subtitles.test.ts @@ -33,6 +33,14 @@ mock.module("../src/token-service.ts", () => ({ poToken: PO_TOKEN, streamingPot: `pot-${videoId}`, })), + fetchSessionPoTokens: mock(async (videoId: string, sessionBinding: string) => ({ + visitorData: VISITOR_DATA, + visitorBoundPoToken: PO_TOKEN, + videoBoundPoToken: `pot-${videoId}`, + poToken: PO_TOKEN, + streamingPot: `pot-${videoId}`, + sessionBoundPoToken: `pot-${sessionBinding}`, + })), })); const frTrack = { diff --git a/tests/token-service.test.ts b/tests/token-service.test.ts index c3fb219..8f58008 100644 --- a/tests/token-service.test.ts +++ b/tests/token-service.test.ts @@ -1,6 +1,6 @@ import { beforeAll, describe, expect, it, mock } from "bun:test"; import type { IntegrityTokenData } from "bgutils-js"; -import type { TokenResult } from "../src/token-service.ts"; +import type { SessionTokenResult, TokenResult } from "../src/token-service.ts"; const VISITOR_DATA = "visitor-data-test-123"; const INTEGRITY_TOKEN = "integrity-token-test-xyz"; @@ -43,11 +43,17 @@ let fetchPoToken: ( forceRefresh?: boolean, refreshVideo?: boolean, ) => Promise; +let fetchSessionPoTokens: ( + videoId: string, + sessionBinding: string, + refreshVideo?: boolean, +) => Promise; describe("fetchPoToken", () => { beforeAll(async () => { const module = await import("../src/token-service.ts?token-service-test"); fetchPoToken = module.fetchPoToken; + fetchSessionPoTokens = module.fetchSessionPoTokens; }); it("does not call buildSession twice on concurrent requests", async () => { @@ -86,6 +92,25 @@ describe("fetchPoToken", () => { expect(mockExecuteBotGuard.mock.calls.length).toBe(1); }); + it("mints session and video tokens from one BotGuard session", async () => { + const callsBefore = mockExecuteBotGuard.mock.calls.length; + const result = await fetchSessionPoTokens("kids-video", "account-visitor"); + + expect(result.visitorData).toBe(currentVisitorData); + expect(result.videoBoundPoToken).toBe("pot-kids-video"); + expect(result.sessionBoundPoToken).toBe("pot-account-visitor"); + expect(mockExecuteBotGuard.mock.calls.length).toBe(callsBefore); + }); + + it("refreshes the video token without changing the session binding", async () => { + const callsBefore = mockMintPoToken.mock.calls.length; + const result = await fetchSessionPoTokens("kids-video", "account-visitor", true); + + expect(result.videoBoundPoToken).toBe("pot-kids-video"); + expect(result.sessionBoundPoToken).toBe("pot-account-visitor"); + expect(mockMintPoToken.mock.calls.length).toBe(callsBefore + 1); + }); + it("uses cached session on subsequent calls without re-initializing", async () => { const callsBefore = mockExecuteBotGuard.mock.calls.length;