// rnx cli auth resolution for cloud-hitting commands. // // priority order (first match wins): // 1. RNX_API_KEY — ci path // 2. CONTRAST_INSTALLATION_TOKEN/GITHUB_TOKEN + repo env — github action // preview uploads from the Contrast runner, no extra secret // 3. desktop session token from shared desktop auth (`rnx login`) // // callers wanting an auth header: `authHeaderOrExit('upload')`. import { readSharedDesktopAuthSession, sharedDesktopAuthAccounts, } from '../src/auth/shared-session.ts' import { rnxExit } from './run-rnx.ts' export type CliApiKeyEnvName = 'RNX_API_KEY' export type CliAuth = | { kind: 'api-key'; secret: string; source: 'env'; envName: CliApiKeyEnvName } | { kind: 'github' token: string // historical name: this is the github owner/repo slug, not the // numeric repository id. repoId: string repositoryId: string | null owner: string | null repo: string | null installationId: string | null source: 'contrast-runner' | 'github-actions' } | { kind: 'session' token: string origin: string // the account this sign-in bills cloud sessions to. null when the person // belongs to several accounts and has not chosen one with `rnx login // --account`; a person with exactly one account never has to choose. accountId: string | null } export type GitHubUploadIdentity = { repoId: string owner?: string repo?: string installationId?: string | null } // ─── resolution ──────────────────────────────────────────────────────────── const GITHUB_TOKEN_PREFIXES = ['ghs_', 'ghp_', 'gho_', 'ghu_', 'github_pat_'] function looksLikeGitHubToken(token: string | undefined): boolean { // github's 2026 stateless installation tokens (`secrets.GITHUB_TOKEN` / // github.token in actions) are ~520 chars and vary by payload — keep them // opaque with NO upper length cap, matching the canonical server-side check // in src/github/token.ts. the length floor + prefix is only a cheap typo // guard before the cloud endpoints verify real repo access. an upper cap // here silently rejects the long actions token, so resolveCliAuth() returns // null and every CI `record start` dies with "set RNX_API_KEY". return Boolean( token && token.length >= 20 && GITHUB_TOKEN_PREFIXES.some((prefix) => token.startsWith(prefix)), ) } function resolveGitHubActionsAuth(): CliAuth | null { const contrastRunnerToken = process.env.CONTRAST_INSTALLATION_TOKEN?.trim() const githubActionsToken = process.env.GITHUB_TOKEN?.trim() const token = contrastRunnerToken || githubActionsToken if (!looksLikeGitHubToken(token)) return null const repoSlug = ( process.env.CONTRAST_REPO || process.env.GITHUB_REPOSITORY || '' ).trim() if (!repoSlug) return null const [owner, repo] = repoSlug.includes('/') ? repoSlug.split('/', 2) : [null, null] const repositoryId = ( process.env.CONTRAST_REPO_ID || process.env.GITHUB_REPOSITORY_ID || '' ).trim() const installationId = ( process.env.CONTRAST_INSTALLATION_ID || process.env.GITHUB_APP_INSTALLATION_ID || '' ).trim() return { kind: 'github', token: token!, repoId: repoSlug, repositoryId: repositoryId || null, owner: owner || null, repo: repo || null, installationId: installationId || null, source: contrastRunnerToken ? 'contrast-runner' : 'github-actions', } } function isCliApiKeySecret(secret: string): boolean { return secret.startsWith('sk_rnx_') } function readApiKeyFromEnv(): { secret: string; envName: CliApiKeyEnvName } | null { const envName = 'RNX_API_KEY' const secret = process.env[envName]?.trim() return secret && isCliApiKeySecret(secret) ? { secret, envName } : null } export function resolveCliAuth(): CliAuth | null { const envKey = readApiKeyFromEnv() if (envKey) { return { kind: 'api-key', secret: envKey.secret, source: 'env', envName: envKey.envName, } } const githubAuth = resolveGitHubActionsAuth() if (githubAuth) return githubAuth const session = readSharedDesktopAuthSession() if (session?.token) { const accounts = sharedDesktopAuthAccounts(session.user) return { kind: 'session', token: session.token, origin: session.origin, accountId: session.accountId ?? (accounts.length === 1 ? (accounts[0]?.id ?? null) : null), } } return null } export const CHOOSE_ACCOUNT_HINT = 'this sign-in belongs to several accounts; run `rnx login --account ` to choose the one to bill' // the account a request that opens a paid cloud session names. an api key // names its own account, so it sends none; a session token has to say. export function cloudAccountIdOrExit(auth: CliAuth): string | null { if (auth.kind !== 'session') return null if (auth.accountId) return auth.accountId process.stderr.write(`\n ${CHOOSE_ACCOUNT_HINT}\n\n`) rnxExit(1) } export function authHeaderValue(auth: CliAuth): string { return auth.kind === 'api-key' ? `Bearer ${auth.secret}` : `Bearer ${auth.token}` } export function githubUploadIdentity(auth: CliAuth | null): GitHubUploadIdentity | null { if (!auth || auth.kind !== 'github') return null return { repoId: auth.repoId, owner: auth.owner ?? undefined, repo: auth.repo ?? undefined, installationId: auth.installationId, } } // fail fast with a friendly message — use at the top of cloud-hitting // commands that can't proceed without auth. export function authHeaderOrExit(commandLabel: string): { auth: CliAuth header: string } { const auth = resolveCliAuth() if (auth) return { auth, header: authHeaderValue(auth) } process.stderr.write( `\n rnx ${commandLabel} needs to be authenticated.\n\n` + ` pick one:\n` + ` • run \`rnx login\` to sign in with your GH account\n` + ` • set RNX_API_KEY=sk_rnx_... (recommended for CI)\n\n`, ) rnxExit(1) }