From b4c68b25eb0763151e27873bbd830563f064b41e Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 6 Aug 2026 10:58:29 +0200 Subject: [PATCH 01/11] chore: prepare token 1.4.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b92fac8..818cdc7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "typetype-token", - "version": "1.3.1", + "version": "1.4.0", "private": true, "license": "MIT", "scripts": { From 263bfb1c22894fdb371707641553dd7b7864907e Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 7 Aug 2026 22:28:37 +0200 Subject: [PATCH 02/11] fix: expose remote login readiness --- src/remote-login-browser.ts | 24 ++++-- src/remote-login-manager.ts | 16 +++- src/remote-login-messages.ts | 10 +++ src/remote-login-routes.ts | 16 +++- tests/remote-login-readiness.test.ts | 108 +++++++++++++++++++++++++++ 5 files changed, 165 insertions(+), 9 deletions(-) create mode 100644 tests/remote-login-readiness.test.ts diff --git a/src/remote-login-browser.ts b/src/remote-login-browser.ts index 5e7846f..52072b7 100644 --- a/src/remote-login-browser.ts +++ b/src/remote-login-browser.ts @@ -64,6 +64,16 @@ function launchOptions(config: RemoteLoginConfig): Parameters[0] { + return { + acceptDownloads: false, + viewport: { width: config.viewportWidth, height: config.viewportHeight }, + deviceScaleFactor: config.deviceScaleFactor, + userAgent: config.userAgent, + locale: config.locale, + }; +} + async function ensureRemoteBrowser(config: RemoteLoginConfig): Promise { if (remoteBrowser?.isConnected()) return remoteBrowser; remoteBrowser = await chromium.launch(launchOptions(config)); @@ -123,13 +133,7 @@ export async function createRemoteLoginPage( onPoToken: (poToken: string) => void, ): Promise { const browser = await ensureRemoteBrowser(config); - const context = await browser.newContext({ - acceptDownloads: false, - viewport: { width: config.viewportWidth, height: config.viewportHeight }, - deviceScaleFactor: config.deviceScaleFactor, - userAgent: config.userAgent, - locale: config.locale, - }); + const context = await browser.newContext(contextOptions(config)); const page = await context.newPage(); page.on("request", (request) => { const poToken = capturePot(request.url()); @@ -145,6 +149,12 @@ export async function createRemoteLoginPage( }; } +export async function probeRemoteLoginBrowser(config: RemoteLoginConfig): Promise { + const browser = await ensureRemoteBrowser(config); + const context = await browser.newContext(contextOptions(config)); + await context.close(); +} + export async function closeRemoteLoginBrowser(): Promise { if (!remoteBrowser) return; await remoteBrowser.close().catch(() => undefined); diff --git a/src/remote-login-manager.ts b/src/remote-login-manager.ts index 12e35c1..4f5003d 100644 --- a/src/remote-login-manager.ts +++ b/src/remote-login-manager.ts @@ -1,4 +1,4 @@ -import { createRemoteLoginPage } from "./remote-login-browser.ts"; +import { createRemoteLoginPage, probeRemoteLoginBrowser } from "./remote-login-browser.ts"; import type { RemoteLoginCompletionTarget } from "./remote-login-callback.ts"; import type { RemoteLoginConfig } from "./remote-login-config.ts"; import type { RemoteLoginStartResponse } from "./remote-login-messages.ts"; @@ -14,18 +14,32 @@ export type RemoteLoginStartOptions = { target: RemoteLoginCompletionTarget; }; +export type RemoteLoginRuntimeProbe = (config: RemoteLoginConfig) => Promise; + export class RemoteLoginManager { private readonly config: RemoteLoginConfig; private readonly createPage: RemoteLoginPageFactory; + private readonly probeRuntime: RemoteLoginRuntimeProbe; private readonly sessions = new Map(); private readonly byUser = new Map(); constructor( config: RemoteLoginConfig, createPage: RemoteLoginPageFactory = createRemoteLoginPage, + probeRuntime: RemoteLoginRuntimeProbe = probeRemoteLoginBrowser, ) { this.config = config; this.createPage = createPage; + this.probeRuntime = probeRuntime; + } + + async isReady(): Promise { + try { + await this.probeRuntime(this.config); + return true; + } catch { + return false; + } } async start(options: RemoteLoginStartOptions): Promise { diff --git a/src/remote-login-messages.ts b/src/remote-login-messages.ts index 7844544..274e50a 100644 --- a/src/remote-login-messages.ts +++ b/src/remote-login-messages.ts @@ -12,6 +12,10 @@ export type RemoteLoginStartResponse = { expiresAt: number; }; +export type RemoteLoginReadinessRequest = { + callbackUrl: string; +}; + export type PointerButton = "left" | "middle" | "right"; export type RemoteLoginInput = @@ -52,6 +56,12 @@ export function parseStartRequest(value: unknown): RemoteLoginStartRequest | nul return { serverSessionId, userId, callbackUrl, ttlMs }; } +export function parseReadinessRequest(value: unknown): RemoteLoginReadinessRequest | null { + if (!isRecord(value)) return null; + const callbackUrl = stringValue(value.callbackUrl, 2048); + return callbackUrl ? { callbackUrl } : null; +} + export function parseRemoteLoginInput(raw: string): RemoteLoginInput | null { const parsed = (() => { try { diff --git a/src/remote-login-routes.ts b/src/remote-login-routes.ts index 5f60b57..7281cee 100644 --- a/src/remote-login-routes.ts +++ b/src/remote-login-routes.ts @@ -5,7 +5,7 @@ import { type RemoteLoginConfig, } from "./remote-login-config.ts"; import type { RemoteLoginManager } from "./remote-login-manager.ts"; -import { parseStartRequest } from "./remote-login-messages.ts"; +import { parseReadinessRequest, parseStartRequest } from "./remote-login-messages.ts"; import type { RemoteLoginConnection } from "./remote-login-session-types.ts"; export type RemoteLoginWebSocketData = { @@ -65,6 +65,20 @@ export function createRemoteLoginHandler(manager: RemoteLoginManager, config: Re url: URL, server?: Server, ): Promise => { + if (url.pathname === "/youtube-remote-login/readiness" && req.method === "POST") { + const denied = requireInternal(req, config); + if (denied) return denied; + const body = parseReadinessRequest(await requestBody(req)); + if (!body) return Response.json({ error: "invalid request body" }, { status: 400 }); + if (!isRemoteLoginCallbackAllowed(body.callbackUrl, config)) { + return Response.json({ error: "callbackUrl is not allowed" }, { status: 400 }); + } + if (!(await manager.isReady())) { + return Response.json({ error: "remote browser is unavailable" }, { status: 503 }); + } + return new Response(null, { status: 204 }); + } + if (url.pathname === "/youtube-remote-login/start" && req.method === "POST") { const denied = requireInternal(req, config); if (denied) return denied; diff --git a/tests/remote-login-readiness.test.ts b/tests/remote-login-readiness.test.ts new file mode 100644 index 0000000..3340d9e --- /dev/null +++ b/tests/remote-login-readiness.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "bun:test"; +import { REMOTE_LOGIN_INTERNAL_HEADER } from "../src/remote-login-config.ts"; +import { RemoteLoginManager } from "../src/remote-login-manager.ts"; +import { createRemoteLoginHandler } from "../src/remote-login-routes.ts"; +import { fakeRemoteLoginPage, remoteLoginTestConfig } from "./remote-login-fixtures.ts"; + +const readinessUrl = new URL("http://localhost:8081/youtube-remote-login/readiness"); + +function readinessRequest(callbackUrl: string, token = "internal-secret"): Request { + return new Request(readinessUrl, { + method: "POST", + headers: { + "content-type": "application/json", + [REMOTE_LOGIN_INTERNAL_HEADER]: token, + }, + body: JSON.stringify({ callbackUrl }), + }); +} + +describe("remote login readiness", () => { + it("returns 404 when remote login is disabled", async () => { + const config = remoteLoginTestConfig({ enabled: false }); + const manager = new RemoteLoginManager(config, async () => fakeRemoteLoginPage()); + const handler = createRemoteLoginHandler(manager, config); + + const res = await handler( + readinessRequest("http://typetype-server.local/internal/callback"), + readinessUrl, + ); + + expect(res?.status).toBe(404); + }); + + it("returns 503 when the internal token is missing", async () => { + const config = remoteLoginTestConfig({ internalToken: null }); + const manager = new RemoteLoginManager(config, async () => fakeRemoteLoginPage()); + const handler = createRemoteLoginHandler(manager, config); + + const res = await handler( + readinessRequest("http://typetype-server.local/internal/callback"), + readinessUrl, + ); + + expect(res?.status).toBe(503); + }); + + it("reports readiness when the callback and browser runtime are usable", async () => { + const config = remoteLoginTestConfig(); + const manager = new RemoteLoginManager( + config, + async () => fakeRemoteLoginPage(), + async () => undefined, + ); + const handler = createRemoteLoginHandler(manager, config); + + const res = await handler( + readinessRequest("http://typetype-server.local/internal/callback"), + readinessUrl, + ); + + expect(res?.status).toBe(204); + }); + + it("rejects a wrong shared secret", async () => { + const config = remoteLoginTestConfig(); + const manager = new RemoteLoginManager(config, async () => fakeRemoteLoginPage()); + const handler = createRemoteLoginHandler(manager, config); + + const res = await handler( + readinessRequest("http://typetype-server.local/internal/callback", "bad-token"), + readinessUrl, + ); + + expect(res?.status).toBe(401); + }); + + it("rejects a callback origin outside the allowlist", async () => { + const config = remoteLoginTestConfig(); + const manager = new RemoteLoginManager(config, async () => fakeRemoteLoginPage()); + const handler = createRemoteLoginHandler(manager, config); + + const res = await handler( + readinessRequest("http://other.local/internal/callback"), + readinessUrl, + ); + + expect(res?.status).toBe(400); + }); + + it("rejects an unavailable browser runtime", async () => { + const config = remoteLoginTestConfig(); + const manager = new RemoteLoginManager( + config, + async () => fakeRemoteLoginPage(), + async () => { + throw new Error("browser unavailable"); + }, + ); + const handler = createRemoteLoginHandler(manager, config); + + const res = await handler( + readinessRequest("http://typetype-server.local/internal/callback"), + readinessUrl, + ); + + expect(res?.status).toBe(503); + }); +}); From 20a5601a6b128f2fae457d3f4e45bd3a90242971 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 8 Aug 2026 12:50:04 +0200 Subject: [PATCH 03/11] chore: update token dependencies --- Dockerfile | 2 +- bun.lock | 30 +++++++++++++++--------------- package.json | 6 +++--- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Dockerfile b/Dockerfile index bd0e16a..9d2b272 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ COPY package.json bun.lock ./ ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 RUN bun install --frozen-lockfile --production -FROM mcr.microsoft.com/playwright:v1.62.0-noble AS runner +FROM mcr.microsoft.com/playwright:v1.62.1-noble AS runner ARG BUILD_VERSION=1.2.4-dev ARG BUILD_REVISION=development ARG BUILD_TIME=unknown diff --git a/bun.lock b/bun.lock index 0b795bf..5d87ae1 100644 --- a/bun.lock +++ b/bun.lock @@ -5,13 +5,13 @@ "": { "name": "typetype-token", "dependencies": { - "bgutils-js": "^4.0.2", + "bgutils-js": "^4.0.3", "googlevideo": "^4.1.1", - "playwright": "^1.62.0", + "playwright": "^1.62.1", "youtubei.js": "^17.2.0", }, "devDependencies": { - "@biomejs/biome": "^2.5.6", + "@biomejs/biome": "^2.5.7", "bun-types": "^1.3.14", }, }, @@ -20,29 +20,29 @@ "@biomejs/biome", ], "packages": { - "@biomejs/biome": ["@biomejs/biome@2.5.6", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.6", "@biomejs/cli-darwin-x64": "2.5.6", "@biomejs/cli-linux-arm64": "2.5.6", "@biomejs/cli-linux-arm64-musl": "2.5.6", "@biomejs/cli-linux-x64": "2.5.6", "@biomejs/cli-linux-x64-musl": "2.5.6", "@biomejs/cli-win32-arm64": "2.5.6", "@biomejs/cli-win32-x64": "2.5.6" }, "bin": { "biome": "bin/biome" } }, "sha512-lxVNjv7UF6KfhMJfL9gaUHbWdJdHbsAj6OSmwSYNdhRuG67NxNQ4Xdvh3TUxsSK9sBzJBQhEJj3AopmmNJ5pSA=="], + "@biomejs/biome": ["@biomejs/biome@2.5.7", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.7", "@biomejs/cli-darwin-x64": "2.5.7", "@biomejs/cli-linux-arm64": "2.5.7", "@biomejs/cli-linux-arm64-musl": "2.5.7", "@biomejs/cli-linux-x64": "2.5.7", "@biomejs/cli-linux-x64-musl": "2.5.7", "@biomejs/cli-win32-arm64": "2.5.7", "@biomejs/cli-win32-x64": "2.5.7" }, "bin": { "biome": "bin/biome" } }, "sha512-zr8K/DcY5tYsQOQwqMJ0AWElo6QgmgNI7idXgXLhevVszlt8RGVpesEJPqx3ThazLaOwjJ5Y8fz3BtH5fGZNsw=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zMOLZP4oMrjh6m1zcSj1ud2awUPgTuMVbmQhYYWL7J8HwCnbHHBvTm7VBTRuY7epT5bez76IpKYQ11ZAqHFlnw=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vxo/Ls3/PYdQWyLhYYcgMOCzQypAjcY+iihS8M0wW03l16TCLW4zqZzGo75gm1VdCMj38hTVZ31KBWrZ4G9dJw=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-JAC1VqzvO7Th5ZplU0G2uGfkZbxEe9uDDektPAhF0JLusoz1w+T4okp2bkykI0bbaO2vslKiRfj4gU43JaGreA=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-Cd3Ga61amT/Yl/0x8elP5hhGYaFy4bw6WuysTgf7oo8TA5tJ5A1k+DkVoJ2BHbTVil51gTX9VPzArnrlLJ3Kyg=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-6XsYwCFkp5sMxl85ffhgeGpGgs6A7dRYFnkceZ7WVxvycuTnGdD5xa534Z3xfrBQ0JCMK/mujT6ZNPJoghedwg=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-rR2QE0yF2GYSuYuKIa7pKvODGJqnOH+2eDREAM8wV+mWKSkMQKdAp4zXEZfTaxY8PMoNONnpgSWcBCyLDPDOKg=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-eUa3jeeYvfMt19LBeh6E5PUZpxnTC4JqNWo+EDjTtQjAr2xLGnWaxACtVU1DQqmHYbvThlJzLX+ZsYgrqh2qVw=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-xPI5yB6XlpDbNkS+bm1t42olw5c4l3UrlOmLg7KtLJvjvkNF/1V4tnUgfkylGIeb3u/T+BzMGYqgQhzjAoJzuQ=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-Pop9VXCFUhFTMfFefZ39S+u2rOPyNp5iHlxbZRwXGACHLy2r0jjiRgJHmaEKJzL3SyxlVeGShXhvvElvWowonA=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-FQgqJhscrqJUFptGaRSUJWlXAExwWcDwLuK49dvKfkQ1bB5SEEyFssnsxQY83Xm6jR0EbbX3+8+D5bfvYqUG2Q=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-2Vp13QdKysH3HIWLaYLhUUwbK+jbZonJD1K+Lr0d0RO4wH7mkYd43vJixEDm8cUWrowoRz4UUHF1nm9Ae7ym8A=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-rE5VZi+qtmPgQH+l7jVxYoZ18b/TiHEhulhMpjmCZH1PltSbjRcxNWywC3HZ9tYottG7ORkeTtoscBilKSBm0g=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-tDGshcm6BdkZOCGnTDX0Y8/U4IfBSlnUU7T56nNDuPEfed+aHg+u8G36NB43fJVl0Os6+QURXIE1yuD7AaEofA=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-Oq4x0CCwP4jirrcTywXs5kOGZ4v5vuEP+gWrbtjApOA2CL9F3F9GlIdQIci8AKSCa/zURanMRpX/4wQ7Am6hHg=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-WN05KwXnTO/2J45RQPvzZMXf7tZUIofHoR35xIPfCo7pQ2RFidxI8sfb5mGsaTxdMmEOzHzOPRCdA5/fCpc7xQ=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ=="], "@bufbuild/protobuf": ["@bufbuild/protobuf@2.12.1", "", {}, "sha512-BvAMfS6LrgZiryOAZ4pBYucu4wG/Ei/9o9DZ9akbREnMLbPJiom2i8b9C8IsKErQoiKqVhrerzt3kOT/RrzLHg=="], "@types/node": ["@types/node@25.4.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw=="], - "bgutils-js": ["bgutils-js@4.0.2", "", {}, "sha512-PyZRxSkwC2MyVtIidD0DejgeNQfb9p3N/wY/d9sa3zAFeMTg6nDMv99wPYoUMU8JhzgG22ZVTnCzAcbfzKPVfQ=="], + "bgutils-js": ["bgutils-js@4.0.3", "", {}, "sha512-nvhrzqRYqFcC3AJbf1fENc/jzyMk95wH9UC9ruQzTZCq+itYWuHi2a9leK/bC1lLfi+R4a28wJZxpD5KiTFf+w=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], @@ -54,9 +54,9 @@ "meriyah": ["meriyah@6.1.4", "", {}, "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ=="], - "playwright": ["playwright@1.62.0", "", { "dependencies": { "playwright-core": "1.62.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw=="], + "playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="], - "playwright-core": ["playwright-core@1.62.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA=="], + "playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], diff --git a/package.json b/package.json index 818cdc7..cc4d8c2 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,13 @@ "format": "biome format --write src tests" }, "dependencies": { - "bgutils-js": "^4.0.2", + "bgutils-js": "^4.0.3", "googlevideo": "^4.1.1", - "playwright": "^1.62.0", + "playwright": "^1.62.1", "youtubei.js": "^17.2.0" }, "devDependencies": { - "@biomejs/biome": "^2.5.6", + "@biomejs/biome": "^2.5.7", "bun-types": "^1.3.14" }, "trustedDependencies": [ From beb3ad93bdf80945224907d61deeb33581343d0e Mon Sep 17 00:00:00 2001 From: Priveetee Date: Tue, 11 Aug 2026 02:59:43 +0200 Subject: [PATCH 04/11] feat: parse page-native BotGuard context --- src/youtube-page-attestation.ts | 88 ++++++++++++++++++++++++++ tests/youtube-page-attestation.test.ts | 43 +++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 src/youtube-page-attestation.ts create mode 100644 tests/youtube-page-attestation.test.ts diff --git a/src/youtube-page-attestation.ts b/src/youtube-page-attestation.ts new file mode 100644 index 0000000..881bdc5 --- /dev/null +++ b/src/youtube-page-attestation.ts @@ -0,0 +1,88 @@ +type PageAttestation = { + eventId: string; + rawChallenge: string; +}; + +const EVENT_ID_PATTERN = /"EVENT_ID"\s*:\s*"([A-Za-z0-9_-]+)"/; +const ATTESTATION_CALL_PATTERN = /window\.ytAtN\s*\(/; +const RESPONSE_PROPERTY_PATTERN = /['"]R['"]\s*:\s*(['"])/; + +export function parseYoutubePageAttestation(pageHtml: string): PageAttestation { + const eventId = EVENT_ID_PATTERN.exec(pageHtml)?.[1]; + if (!eventId) throw new Error("YouTube page has no EVENT_ID"); + + const call = ATTESTATION_CALL_PATTERN.exec(pageHtml); + if (!call) throw new Error("YouTube page has no initial attestation call"); + + const responseOffset = call.index + call[0].length; + const response = RESPONSE_PROPERTY_PATTERN.exec(pageHtml.slice(responseOffset)); + if (!response) throw new Error("YouTube page attestation has no response payload"); + + const quote = response[1]; + const rawChallenge = decodeJavascriptString( + pageHtml, + responseOffset + response.index + response[0].length, + quote, + ); + JSON.parse(rawChallenge); + return { eventId, rawChallenge }; +} + +function decodeJavascriptString(source: string, start: number, quote: string): string { + let result = ""; + let index = start; + while (index < source.length) { + const character = source[index++]; + if (character === quote) return result; + if (character !== "\\") { + result += character; + continue; + } + if (index >= source.length) throw new Error("Incomplete JavaScript string escape"); + const escaped = source[index++]; + switch (escaped) { + case "b": + result += "\b"; + break; + case "f": + result += "\f"; + break; + case "n": + result += "\n"; + break; + case "r": + result += "\r"; + break; + case "t": + result += "\t"; + break; + case "v": + result += "\v"; + break; + case "x": + result += readHexEscape(source, index, 2); + index += 2; + break; + case "u": + result += readHexEscape(source, index, 4); + index += 4; + break; + case "\n": + break; + case "\r": + if (source[index] === "\n") index += 1; + break; + default: + result += escaped; + } + } + throw new Error("Unterminated JavaScript string"); +} + +function readHexEscape(source: string, start: number, length: number): string { + const value = source.slice(start, start + length); + if (value.length !== length || !/^[0-9a-f]+$/i.test(value)) { + throw new Error("Invalid hexadecimal escape"); + } + return String.fromCharCode(Number.parseInt(value, 16)); +} diff --git a/tests/youtube-page-attestation.test.ts b/tests/youtube-page-attestation.test.ts new file mode 100644 index 0000000..9a579da --- /dev/null +++ b/tests/youtube-page-attestation.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "bun:test"; +import { parseYoutubePageAttestation } from "../src/youtube-page-attestation.ts"; + +function pageWith(rawChallenge: string): string { + return ( + `` + ); +} + +describe("parseYoutubePageAttestation", () => { + it("returns the challenge and EVENT_ID from the same page", () => { + const rawChallenge = JSON.stringify({ + bgChallenge: { + program: "line 1\nline 2", + globalName: "trayride", + }, + }); + + expect(parseYoutubePageAttestation(pageWith(rawChallenge))).toEqual({ + eventId: "event_123-abc", + rawChallenge, + }); + }); + + it("decodes hexadecimal JavaScript escapes", () => { + const page = + ''; + + expect(parseYoutubePageAttestation(page).rawChallenge).toBe('{"bgChallenge":{"program":"1"}}'); + }); + + it("rejects pages without a matching page context", () => { + expect(() => parseYoutubePageAttestation("window.ytAtN({'R':'{}'})")).toThrow( + "YouTube page has no EVENT_ID", + ); + expect(() => parseYoutubePageAttestation('{"EVENT_ID":"event-id"}')).toThrow( + "YouTube page has no initial attestation call", + ); + }); +}); From 8ea5c20133e79a96182f7084cd692099cd1bbb07 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Tue, 11 Aug 2026 02:59:50 +0200 Subject: [PATCH 05/11] fix: bind BotGuard to YouTube page context --- src/botguard-challenge.ts | 41 +++++++++-------------------- src/botguard-page.ts | 23 +++++++++++----- src/token-service.ts | 1 + tests/botguard-challenge.test.ts | 34 +++++++++++++----------- tests/token-service-refresh.test.ts | 1 + tests/token-service.test.ts | 9 ++++++- 6 files changed, 58 insertions(+), 51 deletions(-) diff --git a/src/botguard-challenge.ts b/src/botguard-challenge.ts index ad164f1..91cd530 100644 --- a/src/botguard-challenge.ts +++ b/src/botguard-challenge.ts @@ -1,12 +1,12 @@ import { Constants } from "youtubei.js"; import { youtubeFetch } from "./youtube-fetch.ts"; +import { parseYoutubePageAttestation } from "./youtube-page-attestation.ts"; export const WEB_CLIENT_VERSION = Constants.CLIENTS.WEB.VERSION; -const ATT_GET_URL = "https://www.youtube.com/youtubei/v1/att/get?prettyPrint=false"; -const WAA_API_KEY = "AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw"; -const USER_AGENT = +export const WEB_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/131.0.0.0 Safari/537.3"; +const YOUTUBE_HOME_URL = "https://www.youtube.com/"; type AttGetChallenge = { interpreterJavascript?: { @@ -23,6 +23,7 @@ export type BotGuardChallenge = { interpreterScript: string; program: string; globalName: string; + eventId: string; }; async function resolveInterpreterScript(challenge: AttGetChallenge): Promise { @@ -51,38 +52,21 @@ async function resolveInterpreterScript(challenge: AttGetChallenge): Promise { - const response = await youtubeFetch(ATT_GET_URL, { - method: "POST", + const response = await youtubeFetch(YOUTUBE_HOME_URL, { headers: { - "Content-Type": "application/json", - "User-Agent": USER_AGENT, - Accept: "application/json", + "User-Agent": WEB_USER_AGENT, + Accept: "text/html,application/xhtml+xml", + "Accept-Language": "en-US,en;q=0.7", "X-Goog-Visitor-Id": visitorData, - "X-Youtube-Client-Name": "1", - "X-Youtube-Client-Version": WEB_CLIENT_VERSION, - "x-goog-api-key": WAA_API_KEY, - "x-user-agent": "grpc-web-javascript/0.1", }, - body: JSON.stringify({ - engagementType: "ENGAGEMENT_TYPE_UNBOUND", - context: { - client: { - clientName: "WEB", - clientVersion: WEB_CLIENT_VERSION, - hl: "en", - gl: "US", - utcOffsetMinutes: 0, - visitorData, - }, - }, - }), }); if (!response.ok) { - throw new Error(`att/get request failed: ${response.status}`); + throw new Error(`YouTube page request failed: ${response.status}`); } - const data = (await response.json()) as { bgChallenge?: AttGetChallenge }; + const page = parseYoutubePageAttestation(await response.text()); + const data = JSON.parse(page.rawChallenge) as { bgChallenge?: AttGetChallenge }; const challenge = data.bgChallenge; if ( @@ -90,7 +74,7 @@ export async function fetchChallenge(visitorData: string): Promise { }); } if (!page || page.isClosed()) { - page = await browser.newPage({ userAgent: USER_AGENT }); + page = await browser.newPage({ userAgent: WEB_USER_AGENT }); await page.route(ROUTE, (route) => route.fulfill({ contentType: "text/html", body: BLANK_HTML }), ); @@ -27,7 +25,7 @@ async function ensurePage(): Promise { return page; } -type BotGuardArgs = { script: string; prog: string; name: string }; +type BotGuardArgs = { script: string; prog: string; name: string; eventId: string }; type MintArgs = { token: string; id: string }; type PoTokenMinter = (id: Uint8Array) => Uint8Array | Promise; type PoTokenGlobal = Record & { @@ -40,10 +38,23 @@ export async function executeBotGuard( interpreterScript: string, program: string, globalName: string, + eventId: string, ): Promise { const p = await ensurePage(); return p.evaluate( async (args: BotGuardArgs): Promise => { + const root = globalThis as Record; + let yt = root.yt as Record | undefined; + if (!yt) { + yt = {}; + root.yt = yt; + } + let config = yt.config_ as Record | undefined; + if (!config) { + config = {}; + yt.config_ = config; + } + config.EVENT_ID = args.eventId; new Function(args.script)(); const g = globalThis as Record unknown>>; const vm = g[args.name]; @@ -87,7 +98,7 @@ export async function executeBotGuard( af((r: unknown) => resolve(r as string), [undefined, undefined, wpo, undefined]); }); }, - { script: interpreterScript, prog: program, name: globalName }, + { script: interpreterScript, prog: program, name: globalName, eventId }, ); } diff --git a/src/token-service.ts b/src/token-service.ts index 548b969..c0b12e1 100644 --- a/src/token-service.ts +++ b/src/token-service.ts @@ -33,6 +33,7 @@ async function buildSession(): Promise { challenge.interpreterScript, challenge.program, challenge.globalName, + challenge.eventId, ); const integrityTokenData = await fetchIntegrityToken(botguardResponse); diff --git a/tests/botguard-challenge.test.ts b/tests/botguard-challenge.test.ts index f444f81..fb79ac5 100644 --- a/tests/botguard-challenge.test.ts +++ b/tests/botguard-challenge.test.ts @@ -8,38 +8,40 @@ afterEach(() => { }); describe("fetchChallenge", () => { - it("binds att/get to the current visitor session", async () => { + it("binds the page-native challenge to the current visitor session", async () => { let request: Request | null = null; + const rawChallenge = JSON.stringify({ + bgChallenge: { + interpreterJavascript: { + privateDoNotAccessOrElseSafeScriptWrappedValue: "interpreter", + }, + program: "program", + globalName: "trayride", + }, + }); globalThis.fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => { request = new Request(input, init); - return Response.json({ - bgChallenge: { - interpreterJavascript: { - privateDoNotAccessOrElseSafeScriptWrappedValue: "interpreter", - }, - program: "program", - globalName: "trayride", - }, - }); + return new Response( + ``, + ); }) as typeof fetch; const { fetchChallenge, WEB_CLIENT_VERSION } = await import( "../src/botguard-challenge.ts?contract-test" ); const challenge = await fetchChallenge("visitor-session"); - const body = (await request?.json()) as { - context: { client: { visitorData: string; clientVersion: string } }; - }; expect(challenge).toEqual({ interpreterScript: "interpreter", program: "program", globalName: "trayride", + eventId: "event-123", }); expect(WEB_CLIENT_VERSION).toBe(Constants.CLIENTS.WEB.VERSION); + expect(request?.url).toBe("https://www.youtube.com/"); + expect(request?.method).toBe("GET"); expect(request?.headers.get("X-Goog-Visitor-Id")).toBe("visitor-session"); - expect(request?.headers.get("X-Youtube-Client-Name")).toBe("1"); - expect(body.context.client.visitorData).toBe("visitor-session"); - expect(body.context.client.clientVersion).toBe(Constants.CLIENTS.WEB.VERSION); + expect(request?.headers.get("Accept-Language")).toBe("en-US,en;q=0.7"); }); }); diff --git a/tests/token-service-refresh.test.ts b/tests/token-service-refresh.test.ts index 42fc485..c5c807d 100644 --- a/tests/token-service-refresh.test.ts +++ b/tests/token-service-refresh.test.ts @@ -56,6 +56,7 @@ mock.module("../src/botguard-challenge.ts", () => ({ interpreterScript: "interpreter", program: "program", globalName: "trayride", + eventId: "event-id", })), })); diff --git a/tests/token-service.test.ts b/tests/token-service.test.ts index b912b1a..c3fb219 100644 --- a/tests/token-service.test.ts +++ b/tests/token-service.test.ts @@ -7,7 +7,7 @@ const INTEGRITY_TOKEN = "integrity-token-test-xyz"; let currentVisitorData = VISITOR_DATA; const mockExecuteBotGuard = mock( - async (_script: string, _prog: string, _name: string): Promise => + async (_script: string, _prog: string, _name: string, _eventId: string): Promise => "botguard-response-test", ); const mockMintPoToken = mock(async (_token: string, id: string): Promise => `pot-${id}`); @@ -24,6 +24,7 @@ mock.module("../src/botguard-challenge.ts", () => ({ interpreterScript: "/* noop */", program: "program-test", globalName: "vm_test", + eventId: "event-test", })), })); @@ -66,6 +67,12 @@ describe("fetchPoToken", () => { expect(r1.streamingPot).toBe("pot-concurrent-1"); expect(r2.streamingPot).toBe("pot-concurrent-2"); expect(mockExecuteBotGuard.mock.calls.length).toBe(1); + expect(mockExecuteBotGuard).toHaveBeenCalledWith( + "/* noop */", + "program-test", + "vm_test", + "event-test", + ); }); it("returns correct token structure on first call", async () => { From 1982a9d56d6529871c4e83d3c0bd4d12b3a40dbe Mon Sep 17 00:00:00 2001 From: Priveetee Date: Tue, 11 Aug 2026 12:03:14 +0200 Subject: [PATCH 06/11] chore: align development version with 1.5.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cc4d8c2..b961d4b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "typetype-token", - "version": "1.4.0", + "version": "1.5.0", "private": true, "license": "MIT", "scripts": { From f9c1bc6a1ac56fe6cc35e376564aa80d12e11246 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 15 Aug 2026 13:15:12 +0200 Subject: [PATCH 07/11] feat: mint account-bound SABR token pairs --- src/index.ts | 24 +++------------------- src/po-token-routes.ts | 46 ++++++++++++++++++++++++++++++++++++++++++ src/token-service.ts | 28 ++++++++++++++++++++++++- 3 files changed, 76 insertions(+), 22 deletions(-) create mode 100644 src/po-token-routes.ts 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/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, From f664c01da6f38a810f38c4d9f2043b91cebcae6c Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 15 Aug 2026 13:15:12 +0200 Subject: [PATCH 08/11] test: cover account-bound SABR token pairs --- tests/index.test.ts | 4 +++ tests/po-token-routes.test.ts | 55 +++++++++++++++++++++++++++++++++++ tests/token-service.test.ts | 27 ++++++++++++++++- 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 tests/po-token-routes.test.ts 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/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; From d89defad2c26a12886123c48c2eab9805ecf4e52 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 15 Aug 2026 13:16:50 +0200 Subject: [PATCH 09/11] test: complete token service mock contract --- tests/subtitles.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) 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 = { From b02e2f1928dbeff1f09807ebda717fa36e4a7d2f Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 15 Aug 2026 18:15:58 +0200 Subject: [PATCH 10/11] fix: preserve selected YouTube account --- src/remote-login-browser.ts | 11 +++++++++++ src/remote-login-callback.ts | 3 +++ src/remote-login-session.ts | 2 ++ tests/remote-login-callback.test.ts | 10 +++++++++- tests/remote-login-fixtures.ts | 1 + 5 files changed, 26 insertions(+), 1 deletion(-) 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/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, }; From 5d7a778e936a6f80e07840ced9358afce6cfb39b Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 22 Aug 2026 11:16:08 +0200 Subject: [PATCH 11/11] chore: prepare token 1.6.0 --- Dockerfile | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9d2b272..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.2.4-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 b961d4b..9a48aa8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "typetype-token", - "version": "1.5.0", + "version": "1.6.0", "private": true, "license": "MIT", "scripts": {