Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "typetype-token",
"version": "1.5.1",
"version": "1.6.0",
"private": true,
"license": "MIT",
"scripts": {
Expand Down
24 changes: 3 additions & 21 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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";
Expand All @@ -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");
Expand Down
46 changes: 46 additions & 0 deletions src/po-token-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { fetchPoToken, fetchSessionPoTokens } from "./token-service.ts";

export async function handlePoTokenRequest(req: Request, url: URL): Promise<Response | null> {
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<unknown>): Promise<Response> {
try {
return Response.json(await load());
} catch (error) {
const message = error instanceof Error ? error.message : "Internal error";
return Response.json({ error: message }, { status: 500 });
}
}
11 changes: 11 additions & 0 deletions src/remote-login-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export type RemoteLoginPage = {
};
close: () => Promise<void>;
cookies: () => Promise<string>;
authUser: () => Promise<number>;
hasLoginCookie: () => Promise<boolean>;
};

Expand Down Expand Up @@ -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),
};
}
Expand Down
3 changes: 3 additions & 0 deletions src/remote-login-callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type RemoteLoginCompletionPayload = {
status: "completed";
cookies: string;
poToken: string;
authUser: number;
capturedAt: number;
};

Expand All @@ -19,6 +20,7 @@ export async function sendRemoteLoginCompletion(
tokenSessionId: string,
cookies: string,
poToken: string,
authUser: number,
config: RemoteLoginConfig,
): Promise<boolean> {
if (!config.internalToken) return false;
Expand All @@ -30,6 +32,7 @@ export async function sendRemoteLoginCompletion(
status: "completed",
cookies,
poToken,
authUser,
capturedAt: Date.now(),
};

Expand Down
2 changes: 2 additions & 0 deletions src/remote-login-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
28 changes: 27 additions & 1 deletion src/token-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ export type TokenResult = {
streamingPot: string;
};

export type SessionTokenResult = TokenResult & {
sessionBoundPoToken: string;
};

type CachedSession = {
visitorData: string;
visitorBoundPoToken: string;
Expand Down Expand Up @@ -118,10 +122,32 @@ export async function fetchPoToken(
refreshVideo = false,
): Promise<TokenResult> {
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<SessionTokenResult> {
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,
Expand Down
4 changes: 4 additions & 0 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down
55 changes: 55 additions & 0 deletions tests/po-token-routes.test.ts
Original file line number Diff line number Diff line change
@@ -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<SessionTokenResult> => ({
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);
});
});
10 changes: 9 additions & 1 deletion tests/remote-login-callback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ describe("sendRemoteLoginCompletion", () => {
"token-session",
"# Netscape HTTP Cookie File",
"captured-pot",
0,
remoteLoginTestConfig({ internalToken: null }),
);

Expand All @@ -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) => {
Expand All @@ -39,6 +45,7 @@ describe("sendRemoteLoginCompletion", () => {
"token-session",
"# Netscape HTTP Cookie File",
"captured-pot",
2,
config,
);

Expand All @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions tests/remote-login-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export function fakeRemoteLoginPage(overrides: Partial<RemoteLoginPage> = {}): R
},
close: async () => undefined,
cookies: async () => "# Netscape HTTP Cookie File",
authUser: async () => 0,
hasLoginCookie: async () => false,
...overrides,
};
Expand Down
8 changes: 8 additions & 0 deletions tests/subtitles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
27 changes: 26 additions & 1 deletion tests/token-service.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -43,11 +43,17 @@ let fetchPoToken: (
forceRefresh?: boolean,
refreshVideo?: boolean,
) => Promise<TokenResult>;
let fetchSessionPoTokens: (
videoId: string,
sessionBinding: string,
refreshVideo?: boolean,
) => Promise<SessionTokenResult>;

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 () => {
Expand Down Expand Up @@ -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;

Expand Down