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 @@ -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
Expand Down
30 changes: 15 additions & 15 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "typetype-token",
"version": "1.4.0",
"version": "1.5.0",
"private": true,
"license": "MIT",
"scripts": {
Expand All @@ -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": [
Expand Down
24 changes: 17 additions & 7 deletions src/remote-login-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ function launchOptions(config: RemoteLoginConfig): Parameters<typeof chromium.la
};
}

function contextOptions(config: RemoteLoginConfig): Parameters<Browser["newContext"]>[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<Browser> {
if (remoteBrowser?.isConnected()) return remoteBrowser;
remoteBrowser = await chromium.launch(launchOptions(config));
Expand Down Expand Up @@ -123,13 +133,7 @@ export async function createRemoteLoginPage(
onPoToken: (poToken: string) => void,
): Promise<RemoteLoginPage> {
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());
Expand All @@ -145,6 +149,12 @@ export async function createRemoteLoginPage(
};
}

export async function probeRemoteLoginBrowser(config: RemoteLoginConfig): Promise<void> {
const browser = await ensureRemoteBrowser(config);
const context = await browser.newContext(contextOptions(config));
await context.close();
}

export async function closeRemoteLoginBrowser(): Promise<void> {
if (!remoteBrowser) return;
await remoteBrowser.close().catch(() => undefined);
Expand Down
16 changes: 15 additions & 1 deletion src/remote-login-manager.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -14,18 +14,32 @@ export type RemoteLoginStartOptions = {
target: RemoteLoginCompletionTarget;
};

export type RemoteLoginRuntimeProbe = (config: RemoteLoginConfig) => Promise<void>;

export class RemoteLoginManager {
private readonly config: RemoteLoginConfig;
private readonly createPage: RemoteLoginPageFactory;
private readonly probeRuntime: RemoteLoginRuntimeProbe;
private readonly sessions = new Map<string, RemoteLoginSession>();
private readonly byUser = new Map<string, string>();

constructor(
config: RemoteLoginConfig,
createPage: RemoteLoginPageFactory = createRemoteLoginPage,
probeRuntime: RemoteLoginRuntimeProbe = probeRemoteLoginBrowser,
) {
this.config = config;
this.createPage = createPage;
this.probeRuntime = probeRuntime;
}

async isReady(): Promise<boolean> {
try {
await this.probeRuntime(this.config);
return true;
} catch {
return false;
}
}

async start(options: RemoteLoginStartOptions): Promise<RemoteLoginStartResponse | null> {
Expand Down
10 changes: 10 additions & 0 deletions src/remote-login-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ export type RemoteLoginStartResponse = {
expiresAt: number;
};

export type RemoteLoginReadinessRequest = {
callbackUrl: string;
};

export type PointerButton = "left" | "middle" | "right";

export type RemoteLoginInput =
Expand Down Expand Up @@ -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 {
Expand Down
16 changes: 15 additions & 1 deletion src/remote-login-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -65,6 +65,20 @@ export function createRemoteLoginHandler(manager: RemoteLoginManager, config: Re
url: URL,
server?: Server<RemoteLoginWebSocketData>,
): Promise<Response | null | undefined> => {
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;
Expand Down
108 changes: 108 additions & 0 deletions tests/remote-login-readiness.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});