import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { electronUserDataDir } from '../home-paths.ts' export type SharedDesktopAuthTeamSummary = { id: string name: string role: 'owner' | 'member' githubOrg: string | null } export type SharedDesktopAuthUser = { id: string name?: string email?: string image?: string githubUsername?: string /** the person's own account; every team they belong to is in `teams` */ personalAccountId?: string teams?: SharedDesktopAuthTeamSummary[] isAdmin?: boolean } export type SharedDesktopAuthSession = { version: 1 token: string user: SharedDesktopAuthUser | null origin: string source: 'cli' | 'electron' | 'browser' | 'unknown' updatedAt: string validatedAt?: string /** * the account this sign-in bills cloud work to. a session token names the * person, not an account, so the CLI chooses one at `rnx login` and names it * on every request that opens a paid session. */ accountId?: string } /** the accounts a signed-in person can bill: their own, then their teams. */ export function sharedDesktopAuthAccounts( user: SharedDesktopAuthUser | null | undefined, ): { id: string; name: string }[] { return [ ...(user?.personalAccountId ? [{ id: user.personalAccountId, name: 'Personal' }] : []), ...(user?.teams ?? []).map((team) => ({ id: team.id, name: team.name })), ] } const SESSION_VERSION = 1 as const const SESSION_FILE_ENV = 'SOOTSIM_SHARED_AUTH_FILE' const DEFAULT_ORIGIN = 'https://contrast.dev' function getBaseDir() { const explicit = process.env[SESSION_FILE_ENV] if (explicit?.trim()) { return dirname(resolve(explicit)) } return electronUserDataDir() } export function getSharedDesktopAuthFilePath() { const explicit = process.env[SESSION_FILE_ENV] if (explicit?.trim()) return resolve(explicit) return join(getBaseDir(), 'desktop-auth.json') } function normalizeUser(input: unknown): SharedDesktopAuthUser | null { if (!input || typeof input !== 'object') return null const value = input as Record if (typeof value.id !== 'string' || !value.id.trim()) return null return { id: value.id.trim(), name: typeof value.name === 'string' ? value.name : undefined, email: typeof value.email === 'string' ? value.email : undefined, image: typeof value.image === 'string' ? value.image : undefined, githubUsername: typeof value.githubUsername === 'string' ? value.githubUsername : undefined, personalAccountId: typeof value.personalAccountId === 'string' && value.personalAccountId.trim() ? value.personalAccountId.trim() : undefined, isAdmin: value.isAdmin === true, teams: Array.isArray(value.teams) ? value.teams .map((team): SharedDesktopAuthTeamSummary | null => { if (!team || typeof team !== 'object') return null const t = team as Record if (typeof t.id !== 'string' || typeof t.name !== 'string') return null return { id: t.id, name: t.name, role: t.role === 'owner' ? 'owner' : 'member', githubOrg: typeof t.githubOrg === 'string' ? t.githubOrg : null, } }) .filter((team): team is SharedDesktopAuthTeamSummary => !!team) : undefined, } } function normalizeSession(input: unknown): SharedDesktopAuthSession | null { if (!input || typeof input !== 'object') return null const value = input as Record if (value.version !== SESSION_VERSION) return null if (typeof value.token !== 'string' || !value.token.trim()) return null const origin = typeof value.origin === 'string' && value.origin.trim() ? value.origin.trim() : DEFAULT_ORIGIN const source = value.source === 'cli' || value.source === 'electron' || value.source === 'browser' || value.source === 'unknown' ? value.source : 'unknown' const updatedAt = typeof value.updatedAt === 'string' && value.updatedAt ? value.updatedAt : new Date().toISOString() const validatedAt = typeof value.validatedAt === 'string' && value.validatedAt ? value.validatedAt : undefined const accountId = typeof value.accountId === 'string' && value.accountId.trim() ? value.accountId.trim() : undefined return { version: SESSION_VERSION, token: value.token.trim(), user: normalizeUser(value.user), origin, source, updatedAt, validatedAt, accountId, } } export function readSharedDesktopAuthSession(): SharedDesktopAuthSession | null { const filepath = getSharedDesktopAuthFilePath() if (!existsSync(filepath)) return null try { const parsed = JSON.parse(readFileSync(filepath, 'utf8')) as unknown const normalized = normalizeSession(parsed) if (!normalized) { rmSync(filepath, { force: true }) return null } return normalized } catch { rmSync(filepath, { force: true }) return null } } export function writeSharedDesktopAuthSession( session: Omit & { updatedAt?: string }, ) { const filepath = getSharedDesktopAuthFilePath() mkdirSync(dirname(filepath), { recursive: true }) const normalized: SharedDesktopAuthSession = { version: SESSION_VERSION, token: session.token.trim(), user: session.user ? normalizeUser(session.user) : null, origin: session.origin?.trim() || DEFAULT_ORIGIN, source: session.source, updatedAt: session.updatedAt || new Date().toISOString(), validatedAt: session.validatedAt, accountId: session.accountId?.trim() || undefined, } writeFileSync(filepath, JSON.stringify(normalized, null, 2) + '\n') try { chmodSync(filepath, 0o600) } catch { // best-effort only } return normalized } export function clearSharedDesktopAuthSession() { rmSync(getSharedDesktopAuthFilePath(), { force: true }) } export async function refreshSharedDesktopAuthSession( originOverride?: string, ): Promise { const current = readSharedDesktopAuthSession() if (!current?.token) return null const origin = originOverride || current.origin || DEFAULT_ORIGIN try { const res = await fetch(`${origin.replace(/\/$/, '')}/api/auth/me`, { headers: { authorization: `Bearer ${current.token}` }, }) if (res.status === 401) { clearSharedDesktopAuthSession() return null } if (!res.ok) return current const data = (await res.json()) as { user?: SharedDesktopAuthUser | null } if (!data.user?.id) { clearSharedDesktopAuthSession() return null } return writeSharedDesktopAuthSession({ token: current.token, user: data.user, origin, source: current.source, validatedAt: new Date().toISOString(), accountId: current.accountId, }) } catch { return current } }