import { Buffer } from 'node:buffer' import { readFileSync, existsSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' const SESSION_DIR = join(homedir(), '.tela') export function getSessionPath(): string { const apiUrl = getApiBaseUrl() const env = apiUrl.includes('localhost') ? 'local' : apiUrl.includes('staging') ? 'staging' : 'production' return join(SESSION_DIR, `session.${env}`) } export const SESSION_PATH = getSessionPath() export function getApiBaseUrl(): string { return process.env.TELA_API_URL?.trim() || globalThis.__TELA_API_URL__ || 'https://api.tela.com' } export function getAppBaseUrl(): string { const configuredAppUrl = process.env.TELA_APP_URL?.trim() || globalThis.__TELA_APP_URL__ if (configuredAppUrl) return configuredAppUrl try { const apiUrl = new URL(getApiBaseUrl()) if (apiUrl.hostname === 'api.tela.com') return 'https://app.tela.com' if (apiUrl.hostname === 'api.telastaging.com') return 'https://app.telastaging.com' } catch { // Keep the production fallback for invalid or custom API URLs. } return 'https://app.tela.com' } /** * Get the URL to view a project in the Tela app */ export function getProjectUrl(projectId: string): string { return `${getAppBaseUrl()}/project/${projectId}` } /** * Get the URL to view a canvas in the Tela app */ export function getCanvasUrl(canvasId: string): string { return `${getAppBaseUrl()}/prompt/${canvasId}/craft` } export function isJwtExpired(token: string): boolean { try { const payload = JSON.parse(Buffer.from(token.split('.')[1]!, 'base64url').toString()) return typeof payload.exp === 'number' && payload.exp < Math.floor(Date.now() / 1000) } catch { return false } } export function getJwtWorkspace(token: string): { id: string, title: string } | null { try { const payload = JSON.parse(Buffer.from(token.split('.')[1]!, 'base64url').toString()) return payload.workspace ?? null } catch { return null } } function tryRegenerateLocalhostToken(token: string): string | null { try { const workspace = getJwtWorkspace(token) if (!workspace) return null // eslint-disable-next-line ts/no-require-imports -- Keep localhost-only dependencies out of hosted agent startup. const { discoverLocalhostServices, readSavedReposPath, generateLocalhostJwt, saveLocalhostSession } = require('./localhost-utils') as typeof import('./localhost-utils') const reposPath = readSavedReposPath() if (!reposPath) return null const services = discoverLocalhostServices() if (!services.authApiDbPort) return null const newToken = generateLocalhostJwt({ reposPath, workspace, authApiDbPort: services.authApiDbPort, }) saveLocalhostSession(newToken) return newToken } catch { return null } } type DeviceSession = { accessToken: string refreshToken: string authApiUrl: string } function decodeJwtPayload(token: string): Record { try { return JSON.parse(Buffer.from(token.split('.')[1]!, 'base64url').toString()) } catch { return {} } } export function isApplicationToken(token: string): boolean { return decodeJwtPayload(token).token_type === 'application' } /** * Device-flow sessions hold an auth-api application token instead of a data token. The API * gateway only exchanges those for a data token when they arrive as a Bearer credential. */ export function authHeaders(token: string): Record { return isApplicationToken(token) ? { Authorization: `Bearer ${token}` } : { 'x-data-token': token } } const REFRESH_MARGIN_SECONDS = 2 * 60 let pendingRefresh: Promise | null = null // Refresh tokens are single-use, so concurrent callers must share one in-flight refresh. export function refreshSessionIfNeeded(): Promise { pendingRefresh ??= refreshSession().finally(() => { pendingRefresh = null }) return pendingRefresh } async function refreshSession(): Promise { if (process.env.DATA_TOKEN?.trim()) return const sessionPath = getSessionPath() if (!existsSync(sessionPath)) return const content = readFileSync(sessionPath, 'utf-8').trim() if (!content.startsWith('{')) return const session = JSON.parse(content) as DeviceSession const exp = Number(decodeJwtPayload(session.accessToken).exp) if (exp - Math.floor(Date.now() / 1000) > REFRESH_MARGIN_SECONDS) return const response = await fetch(`${session.authApiUrl}/api/auth/applications/token/refresh`, { method: 'POST', headers: { 'x-tela-refresh-token': session.refreshToken }, }) if (!response.ok) throw new Error(`Tela session expired and could not be refreshed (${response.status}). Run the installer to authenticate again.`) const { data } = await response.json() as { data: { accessToken: string, refreshToken: string } } writeFileSync(sessionPath, JSON.stringify({ ...session, accessToken: data.accessToken, refreshToken: data.refreshToken }, null, 2), { mode: 0o600 }) } export async function loadFreshApiKey(): Promise { await refreshSessionIfNeeded() return loadApiKey() } export function loadApiKey(): string { const runtimeToken = process.env.DATA_TOKEN?.trim() if (runtimeToken) return runtimeToken const sessionPath = getSessionPath() if (!existsSync(sessionPath)) { throw new Error(`No DATA_TOKEN or session found at ${sessionPath}. Run the installer to authenticate first.`) } let token = readFileSync(sessionPath, 'utf-8').trim() if (token.startsWith('{')) return (JSON.parse(token) as DeviceSession).accessToken if (getApiBaseUrl().includes('localhost') && isJwtExpired(token)) { const refreshed = tryRegenerateLocalhostToken(token) if (refreshed) { token = refreshed } } return token } /** * Get the active workspace ID from the authenticated session token. * * A missing session throws loadApiKey's own error (run the installer); this only throws when the * token parses but carries no usable workspace claim. */ export function getAuthenticatedWorkspaceId(): string { const workspaceId = getJwtWorkspace(loadApiKey())?.id if (typeof workspaceId !== 'string' || workspaceId.trim() === '') throw new Error('The session token carries no workspace claim. Pass workspaceId explicitly.') return workspaceId.trim() } export function parsePayload(obj: unknown): unknown { if (Array.isArray(obj)) return obj.map(parsePayload) if (obj !== null && typeof obj === 'object') { return Object.fromEntries( Object.entries(obj as Record).map(([key, value]) => { if (typeof value === 'string' && value.startsWith('vault://')) { return [key, key === 'file_url' ? value : { file_url: value }] } return [key, parsePayload(value)] }), ) } return obj } export async function apiRequestRaw(endpoint: string, options: Omit & { body?: unknown } = {}): Promise { const { body, headers, ...rest } = options const serializedBody = body !== undefined && typeof body !== 'string' ? JSON.stringify(parsePayload(body)) : body const apiKey = await loadFreshApiKey() return await fetch(`${getApiBaseUrl()}${endpoint}`, { ...rest, body: serializedBody, headers: { ...authHeaders(apiKey), 'Content-Type': 'application/json', ...headers, }, }) } export class ApiRequestError extends Error { constructor(public readonly status: number, message: string) { super(message) this.name = 'ApiRequestError' } } export async function apiRequest(endpoint: string, options: Omit & { body?: unknown } = {}): Promise { const response = await apiRequestRaw(endpoint, options) if (!response.ok) { const error = await response.text() throw new ApiRequestError(response.status, `API request failed: ${response.status} ${response.statusText} - ${error}`) } // Not every endpoint answers with JSON: deletes send no body, and the tag-assignment // routes reply with a bare `OK`. const body = await response.text() if (!body) return undefined as T try { return JSON.parse(body) as T } catch { return body as T } }