diff --git a/package.json b/package.json index a62d995..3ee29cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hyperbrowser/sdk", - "version": "0.89.4", + "version": "0.90.0", "description": "Node SDK for Hyperbrowser API", "author": "", "repository": { diff --git a/src/client.ts b/src/client.ts index 5325859..104f84b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,4 +1,5 @@ import { HyperbrowserConfig } from "./types/config"; +import { ControlAuthError, resolveControlPlaneConfig } from "./control-auth"; import { SessionsService } from "./services/sessions"; import { ScrapeService } from "./services/scrape"; import { CrawlService } from "./services/crawl"; @@ -74,34 +75,46 @@ export class HyperbrowserClient { public readonly volumes: VolumesService; constructor(config: HyperbrowserConfig = {}) { - const apiKey = config.apiKey || process.env["HYPERBROWSER_API_KEY"]; - const baseUrl = config.baseUrl || "https://api.hyperbrowser.ai"; + let authManager: ReturnType["authManager"]; + let baseUrl = ""; + try { + const resolved = resolveControlPlaneConfig(config); + authManager = resolved.authManager; + baseUrl = resolved.baseUrl; + } catch (error) { + if (error instanceof ControlAuthError) { + throw new HyperbrowserError(error.message, { + statusCode: error.statusCode, + code: error.code, + retryable: error.retryable, + service: "control", + details: error.details, + cause: error.cause ?? error, + }); + } + throw error; + } const timeout = config.timeout || 30000; const runtimeProxyOverride = config.runtimeProxyOverride?.trim() || undefined; - if (!apiKey) { - throw new HyperbrowserError( - "API key is required - either pass it in config or set HYPERBROWSER_API_KEY environment variable" - ); - } - this.sessions = new SessionsService(apiKey, baseUrl, timeout); - this.scrape = new ScrapeService(apiKey, baseUrl, timeout); - this.crawl = new CrawlService(apiKey, baseUrl, timeout); - this.extract = new ExtractService(apiKey, baseUrl, timeout); - this.profiles = new ProfilesService(apiKey, baseUrl, timeout); - this.extensions = new ExtensionService(apiKey, baseUrl, timeout); - this.web = new WebService(apiKey, baseUrl, timeout); - this.team = new TeamService(apiKey, baseUrl, timeout); - this.computerAction = new ComputerActionService(apiKey, baseUrl, timeout); - this.sandboxes = new SandboxesService(apiKey, baseUrl, timeout, runtimeProxyOverride); - this.volumes = new VolumesService(apiKey, baseUrl, timeout); + this.sessions = new SessionsService(authManager, baseUrl, timeout); + this.scrape = new ScrapeService(authManager, baseUrl, timeout); + this.crawl = new CrawlService(authManager, baseUrl, timeout); + this.extract = new ExtractService(authManager, baseUrl, timeout); + this.profiles = new ProfilesService(authManager, baseUrl, timeout); + this.extensions = new ExtensionService(authManager, baseUrl, timeout); + this.web = new WebService(authManager, baseUrl, timeout); + this.team = new TeamService(authManager, baseUrl, timeout); + this.computerAction = new ComputerActionService(authManager, baseUrl, timeout); + this.sandboxes = new SandboxesService(authManager, baseUrl, timeout, runtimeProxyOverride); + this.volumes = new VolumesService(authManager, baseUrl, timeout); this.agents = { - browserUse: new BrowserUseService(apiKey, baseUrl, timeout), - claudeComputerUse: new ClaudeComputerUseService(apiKey, baseUrl, timeout), - cua: new CuaService(apiKey, baseUrl, timeout), - hyperAgent: new HyperAgentService(apiKey, baseUrl, timeout), - geminiComputerUse: new GeminiComputerUseService(apiKey, baseUrl, timeout), + browserUse: new BrowserUseService(authManager, baseUrl, timeout), + claudeComputerUse: new ClaudeComputerUseService(authManager, baseUrl, timeout), + cua: new CuaService(authManager, baseUrl, timeout), + hyperAgent: new HyperAgentService(authManager, baseUrl, timeout), + geminiComputerUse: new GeminiComputerUseService(authManager, baseUrl, timeout), }; } } diff --git a/src/control-auth-errors.ts b/src/control-auth-errors.ts new file mode 100644 index 0000000..e846f46 --- /dev/null +++ b/src/control-auth-errors.ts @@ -0,0 +1,25 @@ +export interface ControlAuthErrorOptions { + statusCode?: number; + code?: string; + retryable?: boolean; + details?: unknown; + cause?: unknown; +} + +export class ControlAuthError extends Error { + public readonly statusCode?: number; + public readonly code?: string; + public readonly retryable: boolean; + public readonly details?: unknown; + public readonly cause?: unknown; + + constructor(message: string, options: ControlAuthErrorOptions = {}) { + super(message); + this.name = "ControlAuthError"; + this.statusCode = options.statusCode; + this.code = options.code; + this.retryable = options.retryable ?? false; + this.details = options.details; + this.cause = options.cause; + } +} diff --git a/src/control-auth-helpers.ts b/src/control-auth-helpers.ts new file mode 100644 index 0000000..608589f --- /dev/null +++ b/src/control-auth-helpers.ts @@ -0,0 +1,116 @@ +import { homedir } from "os"; +import * as path from "path"; +import { ControlAuthError } from "./control-auth-errors"; + +export const DEFAULT_PROFILE = "default"; +export const DEFAULT_BASE_URL = "https://api.hyperbrowser.ai"; +export const DEFAULT_LOCK_TIMEOUT_MS = 30_000; +export const DEFAULT_LOCK_POLL_INTERVAL_MS = 125; +export const DEFAULT_LOCK_STALE_MS = 120_000; +export const OAUTH_REFRESH_EARLY_EXPIRY_MS = 30_000; +export const ENV_PROFILE = "HYPERBROWSER_PROFILE"; +export const ENV_API_KEY = "HYPERBROWSER_API_KEY"; +export const ENV_BASE_URL = "HYPERBROWSER_BASE_URL"; +export const ENV_LOCK_TIMEOUT_MS = "HYPERBROWSER_AUTH_LOCK_TIMEOUT_MS"; +export const ENV_LOCK_POLL_INTERVAL_MS = "HYPERBROWSER_AUTH_LOCK_POLL_INTERVAL_MS"; +export const ENV_LOCK_STALE_MS = "HYPERBROWSER_AUTH_LOCK_STALE_MS"; + +const PROFILE_NAME_PATTERN = /^[A-Za-z0-9._-]+$/; +const REDACTED_VALUE = "[REDACTED]"; +const SENSITIVE_DETAIL_KEYS = new Set(["access_token", "refresh_token"]); + +export function resolveOAuthSessionPath(profile: string): string { + return path.join(homedir(), ".hx_config", "auth", `${profile}.json`); +} + +export function normalizeProfile(value?: string | null): string { + const normalized = normalizeText(value); + if (!normalized) { + return DEFAULT_PROFILE; + } + if (!PROFILE_NAME_PATTERN.test(normalized)) { + throw new ControlAuthError( + "Profile names may contain only letters, numbers, dots, underscores, and hyphens", + { + code: "invalid_profile", + retryable: false, + details: { + profile: normalized, + }, + } + ); + } + return normalized; +} + +export function normalizeControlPlaneBaseUrl(value?: string | null): string { + const normalized = normalizeText(value).replace(/\/+$/, "").replace(/\/api$/, ""); + if (!normalized) { + return normalized; + } + + let parsed: URL; + try { + parsed = new URL(normalized); + } catch (cause) { + throw new ControlAuthError(`Invalid control-plane base URL: ${normalized}`, { + code: "invalid_base_url", + retryable: false, + cause, + }); + } + + if ((parsed.pathname && parsed.pathname !== "/") || parsed.search || parsed.hash) { + throw new ControlAuthError( + `Control-plane base URL must be an origin (scheme + host [+ port]) with no path, query, or fragment: ${normalized}`, + { + code: "invalid_base_url", + retryable: false, + } + ); + } + + return normalized; +} + +export function normalizeText(value?: string | null): string { + return (value || "").trim(); +} + +export function normalizePositiveInteger( + explicitValue: number | undefined, + envValue: string | undefined, + fallback: number +): number { + if (typeof explicitValue === "number" && Number.isFinite(explicitValue) && explicitValue > 0) { + return explicitValue; + } + if (envValue) { + const parsed = Number.parseInt(envValue, 10); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + return fallback; +} + +export function resolveOAuthTokenUrl(baseUrl: string): string { + return new URL("/oauth/token", `${baseUrl}/`).toString(); +} + +export function redactSensitiveDetails(value: T): T { + if (Array.isArray(value)) { + return value.map((item) => redactSensitiveDetails(item)) as T; + } + + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record).map(([key, nestedValue]) => [ + key, + SENSITIVE_DETAIL_KEYS.has(key) ? REDACTED_VALUE : redactSensitiveDetails(nestedValue), + ]) + ) as T; + } + + return value; +} diff --git a/src/control-auth-lock.ts b/src/control-auth-lock.ts new file mode 100644 index 0000000..2fd4cc2 --- /dev/null +++ b/src/control-auth-lock.ts @@ -0,0 +1,89 @@ +import { promises as fs } from "fs"; +import * as path from "path"; +import { ControlAuthError } from "./control-auth-errors"; + +export type LockHandle = Awaited>; + +export async function tryAcquireRotationLock(lockPath: string): Promise { + await fs.mkdir(path.dirname(lockPath), { + recursive: true, + mode: 0o700, + }); + await fs.chmod(path.dirname(lockPath), 0o700).catch(() => undefined); + + let handle: LockHandle | undefined; + try { + handle = await fs.open(lockPath, "wx", 0o600); + await handle.writeFile(`pid=${process.pid}\ncreated_at=${new Date().toISOString()}\n`, "utf8"); + await handle.sync(); + return handle; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + return null; + } + await handle?.close().catch(() => undefined); + if (handle) { + await fs + .rm(lockPath, { + force: true, + }) + .catch(() => undefined); + } + throw new ControlAuthError("Failed to create OAuth rotation lock", { + code: "auth_rotation_lock_failed", + retryable: false, + cause: error, + }); + } +} + +export async function clearStaleRotationLock( + lockPath: string, + lockStaleMs: number +): Promise { + let info; + try { + info = await fs.stat(lockPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return; + } + throw new ControlAuthError("Failed to inspect OAuth rotation lock", { + code: "auth_rotation_lock_failed", + retryable: false, + cause: error, + }); + } + + if (Date.now() - info.mtimeMs < lockStaleMs) { + return; + } + + await fs.rm(lockPath, { + force: true, + }); +} + +export async function releaseRotationLock(lockPath: string, handle: LockHandle): Promise { + await handle.close().catch(() => undefined); + await fs + .rm(lockPath, { + force: true, + }) + .catch(() => undefined); +} + +export async function tryReadSessionMtimeMs(sessionPath: string): Promise { + try { + return (await fs.stat(sessionPath)).mtimeMs; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw new ControlAuthError("Failed to inspect saved OAuth session", { + code: "oauth_session_read_failed", + retryable: false, + cause: error, + }); + } +} diff --git a/src/control-auth-request.ts b/src/control-auth-request.ts new file mode 100644 index 0000000..a13ee30 --- /dev/null +++ b/src/control-auth-request.ts @@ -0,0 +1,20 @@ +import { RequestInit } from "node-fetch"; + +export type RequestInitFactory = () => RequestInit | Promise; + +export async function resolveRequestInit(init: RequestInit | RequestInitFactory): Promise { + return typeof init === "function" ? await init() : init; +} + +export function isReplayableBody(body: RequestInit["body"]): boolean { + if (body == null) { + return true; + } + if (typeof body === "string" || Buffer.isBuffer(body) || body instanceof URLSearchParams) { + return true; + } + if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { + return true; + } + return false; +} diff --git a/src/control-auth-session-store.ts b/src/control-auth-session-store.ts new file mode 100644 index 0000000..698b90c --- /dev/null +++ b/src/control-auth-session-store.ts @@ -0,0 +1,246 @@ +import { randomUUID } from "crypto"; +import { promises as fs, readFileSync } from "fs"; +import * as path from "path"; +import { ControlAuthError } from "./control-auth-errors"; +import { + normalizeControlPlaneBaseUrl, + normalizeText, + OAUTH_REFRESH_EARLY_EXPIRY_MS, + redactSensitiveDetails, +} from "./control-auth-helpers"; +import { type OAuthSessionFile } from "./control-auth-types"; + +export async function loadOAuthSession( + sessionPath: string, + expectedBaseUrl: string +): Promise { + let raw: string; + try { + raw = await fs.readFile(sessionPath, "utf8"); + } catch (error) { + throw new ControlAuthError("Failed to read saved OAuth session", { + code: "oauth_session_read_failed", + retryable: false, + cause: error, + }); + } + + let parsed: OAuthSessionFile; + try { + parsed = JSON.parse(raw) as OAuthSessionFile; + } catch (error) { + throw new ControlAuthError("Saved OAuth session is invalid JSON", { + code: "oauth_session_invalid", + retryable: false, + cause: error, + }); + } + + validateOAuthSession(parsed, expectedBaseUrl); + return parsed; +} + +export function tryLoadOAuthSessionSync(sessionPath: string): OAuthSessionFile | null { + let raw: string; + try { + raw = readFileSync(sessionPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw new ControlAuthError("Failed to read saved OAuth session", { + code: "oauth_session_read_failed", + retryable: false, + cause: error, + }); + } + + let parsed: OAuthSessionFile; + try { + parsed = JSON.parse(raw) as OAuthSessionFile; + } catch (error) { + throw new ControlAuthError("Saved OAuth session is invalid JSON", { + code: "oauth_session_invalid", + retryable: false, + cause: error, + }); + } + + validateOAuthSession(parsed); + return parsed; +} + +export function shouldUseOAuthSession( + session: OAuthSessionFile, + forceRefresh: boolean, + rejectedAccessToken?: string +): boolean { + if (!isAccessTokenUsable(session)) { + return false; + } + if (!forceRefresh) { + return true; + } + return normalizeText(session.access_token) !== normalizeText(rejectedAccessToken); +} + +export function isRefreshTokenExpired(session: OAuthSessionFile): boolean { + const refreshExpiry = parseTimestamp(session.refresh_token_expiry); + if (!refreshExpiry) { + return false; + } + return refreshExpiry.getTime() <= Date.now(); +} + +export function buildRefreshedOAuthSession( + previous: OAuthSessionFile, + payload: Record +): OAuthSessionFile { + const nextAccessToken = normalizeText( + typeof payload.access_token === "string" ? payload.access_token : "" + ); + if (!nextAccessToken) { + throw new ControlAuthError("OAuth refresh response did not include an access token", { + code: "oauth_refresh_failed", + retryable: false, + details: redactSensitiveDetails(payload), + }); + } + + const nextRefreshToken = + normalizeText(typeof payload.refresh_token === "string" ? payload.refresh_token : "") || + normalizeText(previous.refresh_token); + const nextTokenType = + normalizeText(typeof payload.token_type === "string" ? payload.token_type : "") || + normalizeText(previous.token_type) || + "Bearer"; + const expiresAt = deriveOAuthExpiry(payload, "expires_in") || normalizeText(previous.expiry); + const refreshTokenExpiry = + deriveOAuthExpiry(payload, "refresh_token_expires_in") || + normalizeText(previous.refresh_token_expiry); + + return { + version: previous.version, + base_url: normalizeControlPlaneBaseUrl(previous.base_url), + client_id: normalizeText(previous.client_id) || "hyperbrowser-cli", + token_type: nextTokenType, + access_token: nextAccessToken, + refresh_token: nextRefreshToken, + expiry: expiresAt, + scope: + normalizeText(typeof payload.scope === "string" ? payload.scope : "") || + normalizeText(previous.scope), + refresh_token_expiry: refreshTokenExpiry || undefined, + }; +} + +export async function writeOAuthSessionAtomic( + sessionPath: string, + session: OAuthSessionFile +): Promise { + const authDir = path.dirname(sessionPath); + await fs.mkdir(authDir, { + recursive: true, + mode: 0o700, + }); + await fs.chmod(authDir, 0o700).catch(() => undefined); + + const tempPath = path.join( + authDir, + `${path.basename(sessionPath)}.${process.pid}.${Date.now()}.${randomUUID()}.tmp` + ); + const payload = `${JSON.stringify(session, null, 2)}\n`; + const handle = await fs.open(tempPath, "wx", 0o600); + let renamed = false; + + try { + await handle.writeFile(payload, "utf8"); + await handle.sync(); + await handle.close(); + await fs.rename(tempPath, sessionPath); + renamed = true; + await fs.chmod(sessionPath, 0o600).catch(() => undefined); + } finally { + if (!renamed) { + await handle.close().catch(() => undefined); + await fs + .rm(tempPath, { + force: true, + }) + .catch(() => undefined); + } + } +} + +function validateOAuthSession(session: OAuthSessionFile, expectedBaseUrl?: string): void { + if (!session || typeof session !== "object") { + throw new ControlAuthError("Saved OAuth session is missing", { + code: "oauth_session_invalid", + retryable: false, + }); + } + if (normalizeText(session.access_token) === "" || normalizeText(session.refresh_token) === "") { + throw new ControlAuthError("Saved OAuth session is missing tokens", { + code: "oauth_session_invalid", + retryable: false, + }); + } + if (!parseTimestamp(session.expiry)) { + throw new ControlAuthError("Saved OAuth session has an invalid expiry", { + code: "oauth_session_invalid", + retryable: false, + }); + } + if ( + normalizeText(session.refresh_token_expiry) !== "" && + !parseTimestamp(session.refresh_token_expiry) + ) { + throw new ControlAuthError("Saved OAuth session has an invalid refresh token expiry", { + code: "oauth_session_invalid", + retryable: false, + }); + } + if ( + expectedBaseUrl && + normalizeControlPlaneBaseUrl(session.base_url) !== normalizeControlPlaneBaseUrl(expectedBaseUrl) + ) { + throw new ControlAuthError("Saved OAuth session targets a different base URL", { + code: "oauth_base_url_mismatch", + retryable: false, + }); + } +} + +function isAccessTokenUsable(session: OAuthSessionFile): boolean { + const expiry = parseTimestamp(session.expiry); + if (!expiry || normalizeText(session.access_token) === "") { + return false; + } + return expiry.getTime() - Date.now() > OAUTH_REFRESH_EARLY_EXPIRY_MS; +} + +function parseTimestamp(value?: string | null): Date | null { + const normalized = normalizeText(value || ""); + if (!normalized) { + return null; + } + const parsed = new Date(normalized); + if (Number.isNaN(parsed.getTime())) { + return null; + } + return parsed; +} + +function deriveOAuthExpiry(payload: Record, key: string): string | undefined { + const raw = payload[key]; + if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) { + return new Date(Date.now() + raw * 1000).toISOString(); + } + if (typeof raw === "string") { + const parsed = Number.parseInt(raw, 10); + if (Number.isFinite(parsed) && parsed > 0) { + return new Date(Date.now() + parsed * 1000).toISOString(); + } + } + return undefined; +} diff --git a/src/control-auth-types.ts b/src/control-auth-types.ts new file mode 100644 index 0000000..e07b466 --- /dev/null +++ b/src/control-auth-types.ts @@ -0,0 +1,34 @@ +export type OAuthSessionFile = { + version: number; + base_url: string; + client_id: string; + token_type?: string; + access_token: string; + refresh_token: string; + expiry: string; + scope?: string; + refresh_token_expiry?: string; +}; + +export type ControlPlaneAuthMode = + | { + kind: "api_key"; + apiKey: string; + } + | { + kind: "oauth"; + profile: string; + sessionPath: string; + lockPath: string; + baseUrl: string; + lockTimeoutMs: number; + lockPollIntervalMs: number; + lockStaleMs: number; + }; + +export type OAuthControlPlaneAuthMode = Extract; + +export type AuthorizedHeaders = { + headers: Record; + accessToken?: string; +}; diff --git a/src/control-auth.ts b/src/control-auth.ts new file mode 100644 index 0000000..a998db2 --- /dev/null +++ b/src/control-auth.ts @@ -0,0 +1,345 @@ +import fetch, { RequestInit, Response } from "node-fetch"; +import { HyperbrowserConfig } from "./types/config"; +import { ControlAuthError, type ControlAuthErrorOptions } from "./control-auth-errors"; +import { + DEFAULT_BASE_URL, + DEFAULT_LOCK_POLL_INTERVAL_MS, + DEFAULT_LOCK_STALE_MS, + DEFAULT_LOCK_TIMEOUT_MS, + ENV_API_KEY, + ENV_BASE_URL, + ENV_LOCK_POLL_INTERVAL_MS, + ENV_LOCK_STALE_MS, + ENV_LOCK_TIMEOUT_MS, + ENV_PROFILE, + normalizeControlPlaneBaseUrl, + normalizePositiveInteger, + normalizeProfile, + normalizeText, + redactSensitiveDetails, + resolveOAuthSessionPath, + resolveOAuthTokenUrl, +} from "./control-auth-helpers"; +import { + clearStaleRotationLock as clearRotationLockIfStale, + releaseRotationLock as releaseAcquiredRotationLock, + tryAcquireRotationLock as acquireRotationLock, + tryReadSessionMtimeMs, + type LockHandle, +} from "./control-auth-lock"; +import { isReplayableBody, RequestInitFactory, resolveRequestInit } from "./control-auth-request"; +import { + buildRefreshedOAuthSession, + isRefreshTokenExpired, + loadOAuthSession, + shouldUseOAuthSession, + tryLoadOAuthSessionSync, + writeOAuthSessionAtomic, +} from "./control-auth-session-store"; +import { + type AuthorizedHeaders, + type ControlPlaneAuthMode, + type OAuthControlPlaneAuthMode, + type OAuthSessionFile, +} from "./control-auth-types"; + +type ResolvedControlPlaneConfig = { + baseUrl: string; + authManager: ControlPlaneAuthManager; +}; + +export { ControlAuthError, type ControlAuthErrorOptions }; +export { normalizeControlPlaneBaseUrl }; +export type { RequestInitFactory }; + +export function resolveControlPlaneConfig( + config: HyperbrowserConfig = {} +): ResolvedControlPlaneConfig { + const explicitApiKey = normalizeText(config.apiKey); + const envApiKey = normalizeText(process.env[ENV_API_KEY]); + const explicitBaseUrl = normalizeControlPlaneBaseUrl(config.baseUrl); + const envBaseUrl = normalizeControlPlaneBaseUrl(process.env[ENV_BASE_URL]); + const configuredBaseUrl = explicitBaseUrl || envBaseUrl; + + if (explicitApiKey || envApiKey) { + return { + baseUrl: configuredBaseUrl || DEFAULT_BASE_URL, + authManager: new ControlPlaneAuthManager({ + kind: "api_key", + apiKey: explicitApiKey || envApiKey || "", + }), + }; + } + + const profile = normalizeProfile(config.profile || process.env[ENV_PROFILE]); + const sessionPath = resolveOAuthSessionPath(profile); + const session = tryLoadOAuthSessionSync(sessionPath); + const resolvedBaseUrl = + configuredBaseUrl || normalizeControlPlaneBaseUrl(session?.base_url) || DEFAULT_BASE_URL; + + if (!session) { + throw new ControlAuthError( + "API key is required - either pass it in config, set HYPERBROWSER_API_KEY, or save an OAuth session with hx auth login", + { + code: "missing_auth", + retryable: false, + } + ); + } + + if (configuredBaseUrl && normalizeControlPlaneBaseUrl(session.base_url) !== resolvedBaseUrl) { + throw new ControlAuthError( + `Saved OAuth session for profile ${profile} targets ${normalizeControlPlaneBaseUrl(session.base_url)}, not ${resolvedBaseUrl}`, + { + code: "oauth_base_url_mismatch", + retryable: false, + } + ); + } + + return { + baseUrl: resolvedBaseUrl, + authManager: new ControlPlaneAuthManager({ + kind: "oauth", + profile, + sessionPath, + lockPath: `${sessionPath}.refresh.lock`, + baseUrl: resolvedBaseUrl, + lockTimeoutMs: normalizePositiveInteger( + config.authLockTimeoutMs, + process.env[ENV_LOCK_TIMEOUT_MS], + DEFAULT_LOCK_TIMEOUT_MS + ), + lockPollIntervalMs: normalizePositiveInteger( + config.authLockPollIntervalMs, + process.env[ENV_LOCK_POLL_INTERVAL_MS], + DEFAULT_LOCK_POLL_INTERVAL_MS + ), + lockStaleMs: normalizePositiveInteger( + config.authLockStaleMs, + process.env[ENV_LOCK_STALE_MS], + DEFAULT_LOCK_STALE_MS + ), + }), + }; +} + +export class ControlPlaneAuthManager { + constructor(private readonly mode: ControlPlaneAuthMode) {} + + get isOAuth(): boolean { + return this.mode.kind === "oauth"; + } + + async fetch( + url: string, + init: RequestInit | RequestInitFactory = {}, + timeout: number + ): Promise { + const firstAttempt = await this.execute(url, init, timeout, false); + if (firstAttempt.response.status !== 401 || firstAttempt.accessToken === undefined) { + return firstAttempt.response; + } + + if (!this.canReplayRequest(init, firstAttempt.init)) { + return firstAttempt.response; + } + + (firstAttempt.response.body as { destroy?: () => void } | null)?.destroy?.(); + const retryAttempt = await this.execute(url, init, timeout, true, firstAttempt.accessToken); + return retryAttempt.response; + } + + private async execute( + url: string, + init: RequestInit | RequestInitFactory, + timeout: number, + forceRefresh: boolean, + rejectedAccessToken?: string + ): Promise<{ response: Response; accessToken?: string; init: RequestInit }> { + const requestInit = await resolveRequestInit(init); + const authorization = await this.getAuthorizedHeaders(forceRefresh, rejectedAccessToken); + return { + response: await fetch(url, { + ...requestInit, + timeout, + headers: { + ...(requestInit.headers as Record | undefined), + ...authorization.headers, + }, + }), + accessToken: authorization.accessToken, + init: requestInit, + }; + } + + private canReplayRequest( + init: RequestInit | RequestInitFactory, + resolvedInit: RequestInit + ): boolean { + if (typeof init === "function") { + return true; + } + return isReplayableBody(resolvedInit.body); + } + + private async getAuthorizedHeaders( + forceRefresh: boolean, + rejectedAccessToken?: string + ): Promise { + if (this.mode.kind === "api_key") { + return { + headers: { + "x-api-key": this.mode.apiKey, + }, + }; + } + + const accessToken = await this.resolveOAuthAccessToken(forceRefresh, rejectedAccessToken); + return { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + accessToken, + }; + } + + private async resolveOAuthAccessToken( + forceRefresh: boolean, + rejectedAccessToken?: string + ): Promise { + const oauthMode = this.requireOAuthMode(); + let session = await loadOAuthSession(oauthMode.sessionPath, oauthMode.baseUrl); + let sessionMtimeMs = await tryReadSessionMtimeMs(oauthMode.sessionPath); + if (shouldUseOAuthSession(session, forceRefresh, rejectedAccessToken)) { + return session.access_token.trim(); + } + + const deadline = Date.now() + oauthMode.lockTimeoutMs; + while (true) { + const lockHandle = await this.tryAcquireRotationLock(); + if (lockHandle) { + try { + session = await loadOAuthSession(oauthMode.sessionPath, oauthMode.baseUrl); + sessionMtimeMs = await tryReadSessionMtimeMs(oauthMode.sessionPath); + if (shouldUseOAuthSession(session, forceRefresh, rejectedAccessToken)) { + return session.access_token.trim(); + } + if (isRefreshTokenExpired(session)) { + throw new ControlAuthError("OAuth session refresh token expired", { + code: "oauth_session_expired", + retryable: false, + }); + } + const refreshed = await this.refreshOAuthSession(oauthMode, session); + return refreshed.access_token.trim(); + } finally { + await releaseAcquiredRotationLock(oauthMode.lockPath, lockHandle); + } + } + + await clearRotationLockIfStale(oauthMode.lockPath, oauthMode.lockStaleMs); + if (Date.now() > deadline) { + throw new ControlAuthError("Timed out waiting for OAuth rotation lock", { + code: "auth_rotation_timeout", + retryable: false, + }); + } + + await sleep(oauthMode.lockPollIntervalMs); + const nextSessionMtimeMs = await tryReadSessionMtimeMs(oauthMode.sessionPath); + if (nextSessionMtimeMs === sessionMtimeMs) { + continue; + } + sessionMtimeMs = nextSessionMtimeMs; + session = await loadOAuthSession(oauthMode.sessionPath, oauthMode.baseUrl); + if (shouldUseOAuthSession(session, true, rejectedAccessToken)) { + return session.access_token.trim(); + } + if (isRefreshTokenExpired(session)) { + throw new ControlAuthError("OAuth session refresh token expired", { + code: "oauth_session_expired", + retryable: false, + }); + } + } + } + + private async refreshOAuthSession( + oauthMode: OAuthControlPlaneAuthMode, + session: OAuthSessionFile + ): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("client_id", normalizeText(session.client_id) || "hyperbrowser-cli"); + body.set("refresh_token", normalizeText(session.refresh_token)); + + let response: Response; + try { + response = await fetch(resolveOAuthTokenUrl(oauthMode.baseUrl), { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + body: body.toString(), + timeout: oauthMode.lockTimeoutMs, + }); + } catch (error) { + throw new ControlAuthError("Failed to refresh OAuth session", { + code: "oauth_refresh_failed", + retryable: true, + cause: error, + }); + } + + const rawText = await response.text(); + let payload: Record = {}; + if (rawText) { + try { + payload = JSON.parse(rawText) as Record; + } catch { + payload = {}; + } + } + + if (!response.ok) { + const message = + normalizeText(typeof payload.message === "string" ? payload.message : "") || + normalizeText(typeof payload.error === "string" ? payload.error : "") || + `OAuth refresh failed with status ${response.status}`; + throw new ControlAuthError(message, { + statusCode: response.status, + code: + normalizeText(typeof payload.code === "string" ? payload.code : "") || + "oauth_refresh_failed", + retryable: false, + details: redactSensitiveDetails(payload), + }); + } + + const refreshed = buildRefreshedOAuthSession(session, payload); + await writeOAuthSessionAtomic(oauthMode.sessionPath, refreshed); + return refreshed; + } + + private requireOAuthMode(): OAuthControlPlaneAuthMode { + if (this.mode.kind !== "oauth") { + throw new ControlAuthError("OAuth auth is not configured", { + code: "missing_auth", + retryable: false, + }); + } + + return this.mode; + } + + private async tryAcquireRotationLock(): Promise { + return acquireRotationLock(this.requireOAuthMode().lockPath); + } +} + +function sleep(durationMs: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, durationMs); + }); +} diff --git a/src/services/base.ts b/src/services/base.ts index f455424..b5b9522 100644 --- a/src/services/base.ts +++ b/src/services/base.ts @@ -1,4 +1,10 @@ -import fetch, { HeadersInit, RequestInit, Response } from "node-fetch"; +import { HeadersInit, RequestInit, Response } from "node-fetch"; +import { + ControlAuthError, + ControlPlaneAuthManager, + normalizeControlPlaneBaseUrl, + RequestInitFactory, +} from "../control-auth"; import { HyperbrowserError } from "../client"; const RETRYABLE_STATUS_CODES = new Set([429, 502, 503, 504]); @@ -10,6 +16,8 @@ const RETRYABLE_NETWORK_CODES = new Set([ "ESOCKETTIMEDOUT", ]); +const normalizeHeaderKey = (key: string): string => key.toLowerCase(); + const getRequestId = (response: Response): string | undefined => { return response.headers.get("x-request-id") || response.headers.get("request-id") || undefined; }; @@ -27,16 +35,63 @@ const isRetryableNetworkError = (error: unknown): boolean => { ); }; +const toHeaderMap = (headers?: HeadersInit): Record => { + if (!headers) { + return {}; + } + if (Array.isArray(headers)) { + return Object.fromEntries( + headers.map(([key, value]) => [normalizeHeaderKey(key), String(value)]) + ); + } + if (typeof (headers as { forEach?: unknown }).forEach === "function") { + const values: Record = {}; + (headers as { forEach: (callback: (value: string, key: string) => void) => void }).forEach( + (value, key) => { + values[normalizeHeaderKey(key)] = value; + } + ); + return values; + } + return Object.fromEntries( + Object.entries(headers).map(([key, value]) => [ + normalizeHeaderKey(key), + value === undefined ? "" : String(value), + ]) + ); +}; + +const normalizeRequestInit = (init?: RequestInit): RequestInit => { + const requestHeaders = toHeaderMap(init?.headers); + return { + ...init, + headers: { + ...requestHeaders, + "content-type": requestHeaders["content-type"] || "application/json", + }, + }; +}; + export class BaseService { - constructor( - protected readonly apiKey: string, - protected readonly baseUrl: string, - protected readonly timeout: number = 30000 - ) {} + protected readonly auth: ControlPlaneAuthManager; + protected readonly baseUrl: string; + protected readonly timeout: number; + + constructor(auth: string | ControlPlaneAuthManager, baseUrl: string, timeout: number = 30000) { + this.auth = + typeof auth === "string" + ? new ControlPlaneAuthManager({ + kind: "api_key", + apiKey: auth, + }) + : auth; + this.baseUrl = normalizeControlPlaneBaseUrl(baseUrl); + this.timeout = timeout; + } protected async request( path: string, - init?: RequestInit, + init?: RequestInit | RequestInitFactory, params?: Record, fullUrl: boolean = false ): Promise { @@ -57,22 +112,11 @@ export class BaseService { }); } - const headerKeys = Object.keys(init?.headers || {}); - const contentTypeKey = headerKeys.find( - (key) => key.toLowerCase() === "content-type" - ) as keyof HeadersInit; - - const response = await fetch(url.toString(), { - ...init, - timeout: this.timeout, - headers: { - "x-api-key": this.apiKey, - ...(contentTypeKey && init?.headers - ? { "content-type": init.headers[contentTypeKey] as string } - : { "content-type": "application/json" }), - ...init?.headers, - }, - }); + const requestInit = + typeof init === "function" + ? async () => normalizeRequestInit(await init()) + : normalizeRequestInit(init); + const response = await this.auth.fetch(url.toString(), requestInit, this.timeout); if (!response.ok) { let errorMessage: string; @@ -115,6 +159,16 @@ export class BaseService { if (error instanceof HyperbrowserError) { throw error; } + if (error instanceof ControlAuthError) { + throw new HyperbrowserError(error.message, { + statusCode: error.statusCode, + code: error.code, + retryable: error.retryable, + service: "control", + details: error.details, + cause: error.cause ?? error, + }); + } throw new HyperbrowserError( error instanceof Error ? error.message : "Unknown error occurred", diff --git a/src/services/extensions.ts b/src/services/extensions.ts index d387091..de15ff4 100644 --- a/src/services/extensions.ts +++ b/src/services/extensions.ts @@ -32,23 +32,26 @@ export class ExtensionService extends BaseService { async create(params: CreateExtensionParams): Promise { try { await checkFileExists(params.filePath); + const extensionBuffer = await fs.readFile(params.filePath); + const extensionName = path.basename(params.filePath); - const form = new FormData(); - form.append("file", await fs.readFile(params.filePath), { - filename: path.basename(params.filePath), - contentType: "application/zip", - }); + return await this.request("/extensions/add", () => { + const form = new FormData(); + form.append("file", extensionBuffer, { + filename: extensionName, + contentType: "application/zip", + }); - if (params.name) { - form.append("name", params.name); - } + if (params.name) { + form.append("name", params.name); + } - const response = await this.request("/extensions/add", { - method: "POST", - body: form, - headers: form.getHeaders(), + return { + method: "POST", + body: form, + headers: form.getHeaders(), + }; }); - return response; } catch (error) { if (error instanceof HyperbrowserError) { throw error; diff --git a/src/services/sandboxes.ts b/src/services/sandboxes.ts index 826b4c8..ac722a8 100644 --- a/src/services/sandboxes.ts +++ b/src/services/sandboxes.ts @@ -1,4 +1,5 @@ import { HyperbrowserError } from "../client"; +import { ControlPlaneAuthManager } from "../control-auth"; import { SandboxFilesApi } from "../sandbox/files"; import { RuntimeConnection, RuntimeTransport } from "../sandbox/base"; import { runtimeSessionIdFromPath } from "../sandbox/runtime-path"; @@ -443,8 +444,13 @@ export class SandboxesService extends BaseService { public readonly runtimeTimeout: number; public readonly runtimeProxyOverride?: string; - constructor(apiKey: string, baseUrl: string, timeout: number, runtimeProxyOverride?: string) { - super(apiKey, baseUrl, timeout); + constructor( + auth: string | ControlPlaneAuthManager, + baseUrl: string, + timeout: number, + runtimeProxyOverride?: string + ) { + super(auth, baseUrl, timeout); this.runtimeTimeout = timeout; this.runtimeProxyOverride = runtimeProxyOverride; } diff --git a/src/services/scrape.ts b/src/services/scrape.ts index 9d50ed7..115d21d 100644 --- a/src/services/scrape.ts +++ b/src/services/scrape.ts @@ -9,6 +9,7 @@ import { StartScrapeJobParams, StartScrapeJobResponse, } from "../types/scrape"; +import { ControlPlaneAuthManager } from "../control-auth"; import { BaseService } from "./base"; import { sleep } from "../utils"; import { HyperbrowserError } from "../client"; @@ -168,9 +169,9 @@ export class BatchScrapeService extends BaseService { export class ScrapeService extends BaseService { public readonly batch: BatchScrapeService; - constructor(apiKey: string, baseUrl: string, timeout: number) { - super(apiKey, baseUrl, timeout); - this.batch = new BatchScrapeService(apiKey, baseUrl, timeout); + constructor(auth: string | ControlPlaneAuthManager, baseUrl: string, timeout: number) { + super(auth, baseUrl, timeout); + this.batch = new BatchScrapeService(this.auth, this.baseUrl, timeout); } /** diff --git a/src/services/sessions.ts b/src/services/sessions.ts index e8c7fe3..e1ffe3c 100644 --- a/src/services/sessions.ts +++ b/src/services/sessions.ts @@ -2,6 +2,7 @@ import { promises as fs, Stats, createReadStream, ReadStream } from "fs"; import * as path from "path"; import FormData from "form-data"; import { RequestInit } from "node-fetch"; +import { Readable } from "stream"; import { BasicResponse, CreateSessionParams, @@ -21,9 +22,31 @@ import { UpdateSessionProxyParams, SessionGetParams, } from "../types/session"; +import { ControlPlaneAuthManager } from "../control-auth"; import { BaseService } from "./base"; import { HyperbrowserError } from "../client"; +function wrapFileReadErrors(filePath: string, stream: ReadStream): Readable { + return Readable.from( + (async function* () { + try { + for await (const chunk of stream) { + yield chunk; + } + } catch (error) { + if (error instanceof HyperbrowserError) { + throw error; + } + + const message = error instanceof Error ? error.message : String(error); + throw new HyperbrowserError(`Failed to read file ${filePath}: ${message}`, { + cause: error, + }); + } + })() + ); +} + /** * Service for managing session event logs */ @@ -58,9 +81,9 @@ class SessionEventLogsService extends BaseService { export class SessionsService extends BaseService { public readonly eventLogs: SessionEventLogsService; - constructor(apiKey: string, baseUrl: string, timeout: number) { - super(apiKey, baseUrl, timeout); - this.eventLogs = new SessionEventLogsService(apiKey, baseUrl, timeout); + constructor(auth: string | ControlPlaneAuthManager, baseUrl: string, timeout: number) { + super(auth, baseUrl, timeout); + this.eventLogs = new SessionEventLogsService(this.auth, this.baseUrl, timeout); } /** @@ -207,7 +230,7 @@ export class SessionsService extends BaseService { const { fileInput, fileName } = fileOptions; try { - let fetchOptions: RequestInit; + let fetchOptions: RequestInit | (() => Promise | RequestInit); if (typeof fileInput === "string") { let stats: Stats; @@ -233,38 +256,30 @@ export class SessionsService extends BaseService { throw new HyperbrowserError(`Path is not a file: ${fileInput}`, undefined); } - const formData = new FormData(); - const fileStream = createReadStream(fileInput); const fileBaseName = fileName || path.basename(fileInput); - - fileStream.on("error", (error) => { - throw new HyperbrowserError( - `Failed to read file ${fileInput}: ${error.message}`, - undefined - ); - }); - - formData.append("file", fileStream, { - filename: fileBaseName, - }); - - fetchOptions = { - method: "POST", - body: formData, - headers: formData.getHeaders(), + fetchOptions = () => { + const formData = new FormData(); + formData.append("file", wrapFileReadErrors(fileInput, createReadStream(fileInput)), { + filename: fileBaseName, + knownLength: stats.size, + }); + return { + method: "POST", + body: formData, + headers: formData.getHeaders(), + }; }; } else if (this.isReadableStream(fileInput)) { - const formData = new FormData(); - - let tmpFileName = fileName || `file-${Date.now()}`; - if (fileInput.path && typeof fileInput.path === "string" && !fileName) { - tmpFileName = path.basename(fileInput.path); + const streamPath = typeof fileInput.path === "string" ? fileInput.path : ""; + let streamFileName = fileName || `file-${Date.now()}`; + if (streamPath && !fileName) { + streamFileName = path.basename(streamPath); } + const formData = new FormData(); formData.append("file", fileInput, { - filename: tmpFileName, + filename: streamFileName, }); - fetchOptions = { method: "POST", body: formData, @@ -275,15 +290,16 @@ export class SessionsService extends BaseService { throw new HyperbrowserError("fileName is required when uploading Buffer data", undefined); } - const formData = new FormData(); - formData.append("file", fileInput, { - filename: fileName, - }); - - fetchOptions = { - method: "POST", - body: formData, - headers: formData.getHeaders(), + fetchOptions = () => { + const formData = new FormData(); + formData.append("file", fileInput, { + filename: fileName, + }); + return { + method: "POST", + body: formData, + headers: formData.getHeaders(), + }; }; } else { throw new HyperbrowserError( diff --git a/src/services/web/index.ts b/src/services/web/index.ts index 6fb8d38..b2c00a3 100644 --- a/src/services/web/index.ts +++ b/src/services/web/index.ts @@ -1,5 +1,6 @@ import { toJSONSchema } from "zod"; import zodToJsonSchema from "zod-to-json-schema"; +import { ControlPlaneAuthManager } from "../../control-auth"; import { BaseService } from "../base"; import { HyperbrowserError } from "../../client"; import { FetchParams, FetchResponse } from "../../types/web/fetch"; @@ -13,10 +14,10 @@ export class WebService extends BaseService { public readonly batchFetch: BatchFetchService; public readonly crawl: WebCrawlService; - constructor(apiKey: string, baseUrl: string, timeout: number) { - super(apiKey, baseUrl, timeout); - this.batchFetch = new BatchFetchService(apiKey, baseUrl, timeout); - this.crawl = new WebCrawlService(apiKey, baseUrl, timeout); + constructor(auth: string | ControlPlaneAuthManager, baseUrl: string, timeout: number) { + super(auth, baseUrl, timeout); + this.batchFetch = new BatchFetchService(this.auth, this.baseUrl, timeout); + this.crawl = new WebCrawlService(this.auth, this.baseUrl, timeout); } /** * Fetch a URL and extract content diff --git a/src/types/config.ts b/src/types/config.ts index c998375..727d2d7 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1,6 +1,42 @@ export interface HyperbrowserConfig { + /** API key used for control-plane requests. Falls back to `HYPERBROWSER_API_KEY`. */ apiKey?: string; + + /** + * Control-plane origin used for API and OAuth requests. + * Falls back to `HYPERBROWSER_BASE_URL`. + * A trailing `/api` is normalized away for compatibility with existing configs. + */ baseUrl?: string; + + /** + * Saved OAuth profile name from `~/.hx_config/auth/.json`. + * Falls back to `HYPERBROWSER_PROFILE`. + * Only letters, numbers, dots, underscores, and hyphens are allowed. + */ + profile?: string; + + /** Request timeout in milliseconds. */ timeout?: number; + + /** Optional runtime proxy override used for sandbox transport endpoints. */ runtimeProxyOverride?: string; + + /** + * Maximum time in milliseconds to wait for the OAuth refresh lock. + * Falls back to `HYPERBROWSER_AUTH_LOCK_TIMEOUT_MS`. + */ + authLockTimeoutMs?: number; + + /** + * Poll interval in milliseconds while waiting for the OAuth refresh lock. + * Falls back to `HYPERBROWSER_AUTH_LOCK_POLL_INTERVAL_MS`. + */ + authLockPollIntervalMs?: number; + + /** + * Lock age in milliseconds after which a stale OAuth refresh lock can be cleared. + * Falls back to `HYPERBROWSER_AUTH_LOCK_STALE_MS`. + */ + authLockStaleMs?: number; }