// integrations/atlas/uiSession.js // Atmosphere / Xyte UI session API (tunnel URLs live here). // Auth is Devise-token style headers from a logged-in UI session, NOT ATLAS_AUTH_KEY. import { fetchWithTimeout } from '../../utils/fetchWithTimeout.js'; import { XyteHttpError } from './errors.js'; const HUB_BASE = 'https://hub.xyte.io'; const UI_BASE = `${HUB_BASE}/ui/organization`; const AUTH_BASE = `${HUB_BASE}/auth`; const DESCOPE_API = 'https://api.descope.com/v1/auth'; const PORTAL_ORIGIN = 'https://atmosphere.atlasied.com'; /** @type {Record|null} */ let cachedSessionHeaders = null; export function getStaticUiSessionHeaders() { const accessToken = process.env.ATLAS_UI_ACCESS_TOKEN; const client = process.env.ATLAS_UI_CLIENT; const expiry = process.env.ATLAS_UI_EXPIRY; const uid = process.env.ATLAS_UI_UID; const tenant = process.env.ATLAS_UI_TENANT; if (!accessToken || !client || !expiry || !uid || !tenant) { return null; } return buildSessionHeaders({ token: accessToken, client, expiry, uid, tenant: { id: tenant, type: process.env.ATLAS_UI_TENANT_TYPE || 'organization' }, }); } export function hasUiEmailPassword() { return !!(process.env.ATLAS_UI_EMAIL && process.env.ATLAS_UI_PASSWORD); } export function hasUiSession() { return hasUiEmailPassword() || !!getStaticUiSessionHeaders() || !!cachedSessionHeaders; } export function getUiSessionHeaders() { return cachedSessionHeaders || getStaticUiSessionHeaders(); } function buildSessionHeaders({ token, client, expiry, uid, tenant }) { const tenantId = typeof tenant === 'string' ? tenant : tenant?.id; const tenantType = (typeof tenant === 'object' && tenant?.type) || process.env.ATLAS_UI_TENANT_TYPE || 'organization'; const headers = { 'Content-Type': 'application/json', Accept: '*/*', 'access-token': token, client, expiry: String(expiry), uid, 'token-type': process.env.ATLAS_UI_TOKEN_TYPE || 'Bearer', origin: PORTAL_ORIGIN, referer: `${PORTAL_ORIGIN}/`, }; if (tenantId) { headers.tenant = tenantId; headers['tenant-type'] = tenantType; } return headers; } async function fetchPortalConfig() { const response = await fetchWithTimeout( `${UI_BASE}/portal_config`, { method: 'GET', headers: { Accept: 'application/json', origin: PORTAL_ORIGIN, referer: `${PORTAL_ORIGIN}/`, }, }, 20000, ); const data = await response.json().catch(() => null); if (!response.ok) { throw new XyteHttpError( `portal_config failed: ${response.status}`, { status: response.status, body: data, url: `${UI_BASE}/portal_config` }, ); } return data; } export async function descopePasswordSignIn(email, password, projectId) { const url = `${DESCOPE_API}/password/signin`; const response = await fetchWithTimeout( url, { method: 'POST', headers: { Authorization: `Bearer ${projectId}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ loginId: email, password }), }, 20000, ); const data = await response.json().catch(() => null); if (!response.ok || !data?.sessionJwt) { throw new XyteHttpError( `Descope sign-in failed: ${response.status}`, { status: response.status, body: data, url }, ); } return data; } export async function exchangeDescopeLogin(sessionJwt, { tenantType = 'organization', isSupportUser = false, lastTenantId = null, } = {}) { const url = `${AUTH_BASE}/descope/login`; const body = { token: sessionJwt, tenant_type: tenantType, is_support_user: isSupportUser, accepted_terms_and_conditions: true, }; if (lastTenantId) body.last_tenant_id = lastTenantId; const response = await fetchWithTimeout( url, { method: 'POST', headers: { 'Content-Type': 'application/json', origin: PORTAL_ORIGIN, referer: `${PORTAL_ORIGIN}/`, }, body: JSON.stringify(body), }, 20000, ); const data = await response.json().catch(() => null); if (!response.ok || !data?.token) { throw new XyteHttpError( `Descope→UI login exchange failed: ${response.status}`, { status: response.status, body: data, url }, ); } return data; } function resolveTenantId(login) { if (process.env.ATLAS_UI_TENANT) return process.env.ATLAS_UI_TENANT; if (login?.tenant?.id) return login.tenant.id; if (typeof login?.tenant === 'string' && login.tenant) return login.tenant; const access = Array.isArray(login?.access) ? login.access : []; const userId = login?.id; const orgId = access.find(id => id && id !== userId); return orgId || access[0] || null; } export async function signInWithEmailPassword({ email = process.env.ATLAS_UI_EMAIL, password = process.env.ATLAS_UI_PASSWORD, projectId = process.env.ATLAS_UI_DESCOPE_PROJECT_ID, lastTenantId = process.env.ATLAS_UI_TENANT || null, } = {}) { if (!email || !password) { const err = new Error('ATLAS_UI_EMAIL and ATLAS_UI_PASSWORD are required'); err.code = 'MISSING_UI_CREDENTIALS'; throw err; } let descopeProjectId = projectId; if (!descopeProjectId) { const portal = await fetchPortalConfig(); descopeProjectId = portal?.descope_project_id; } if (!descopeProjectId) { throw new Error('Missing Descope project id (portal_config.descope_project_id)'); } const descope = await descopePasswordSignIn(email, password, descopeProjectId); const login = await exchangeDescopeLogin(descope.sessionJwt, { tenantType: process.env.ATLAS_UI_TENANT_TYPE || 'organization', lastTenantId, }); const tenantId = resolveTenantId(login); if (!tenantId) { throw new Error( 'UI login succeeded but no tenant id (set ATLAS_UI_TENANT to your org UUID)', ); } const headers = buildSessionHeaders({ token: login.token, client: login.client, expiry: login.expiry, uid: login.uid, tenant: { id: tenantId, type: process.env.ATLAS_UI_TENANT_TYPE || 'organization' }, }); cachedSessionHeaders = headers; return { headers, login: { email: login.email, name: login.name, uid: login.uid, tenantId, expiry: login.expiry, }, }; } export async function ensureUiSession() { if (cachedSessionHeaders) return cachedSessionHeaders; if (hasUiEmailPassword()) { const { headers } = await signInWithEmailPassword(); return headers; } const staticHeaders = getStaticUiSessionHeaders(); if (staticHeaders) { cachedSessionHeaders = staticHeaders; return staticHeaders; } const err = new Error( 'Missing UI session: set ATLAS_UI_EMAIL + ATLAS_UI_PASSWORD (preferred) or ATLAS_UI_* DevTools headers', ); err.code = 'MISSING_UI_SESSION'; throw err; } export function clearUiSessionCache() { cachedSessionHeaders = null; } async function uiFetch(path, { method = 'GET', body, headers, timeoutMs = 30000 } = {}) { const session = headers || (await ensureUiSession()); if (!session) { const err = new Error( 'Missing UI session (ATLAS_UI_EMAIL/PASSWORD or ATLAS_UI_* headers)', ); err.code = 'MISSING_UI_SESSION'; throw err; } const url = path.startsWith('http') ? path : `${UI_BASE}${path}`; const options = { method, headers: { ...session } }; if (body !== undefined) options.body = JSON.stringify(body); const response = await fetchWithTimeout(url, options, timeoutMs); const text = await response.text().catch(() => ''); let data = null; if (text) { try { data = JSON.parse(text); } catch { data = text; } } if (!response.ok) { throw new XyteHttpError( `Xyte UI ${method} ${url} failed: ${response.status}`, { status: response.status, body: data, url }, ); } return data; } export async function uiOpenTunnel(deviceId, { command = 'Connect' } = {}) { if (!deviceId) throw new Error('deviceId is required'); const data = await uiFetch('/commands', { method: 'POST', body: { command, deviceIds: [deviceId], extra_params: {}, }, }); const cmd = Array.isArray(data) ? data[0] : data; if (!cmd?.tunnel_redirect_url) { throw new Error('UI Connect response missing tunnel_redirect_url'); } return cmd; } export async function pollTunnelStatus(statusUrl, { timeoutSeconds = 90, intervalMs = 1000 } = {}) { const deadline = Date.now() + timeoutSeconds * 1000; let last = null; while (Date.now() < deadline) { const response = await fetchWithTimeout(statusUrl, { method: 'GET' }, 15000); const text = await response.text(); last = JSON.parse(text); if (last?.connected) return last; await new Promise(r => setTimeout(r, intervalMs)); } const err = new Error('Timed out waiting for tunnel connected=true'); err.lastStatus = last; throw err; } function getSetCookies(res) { if (typeof res.headers.getSetCookie === 'function') { const arr = res.headers.getSetCookie(); if (arr?.length) return arr; } const single = res.headers.get('set-cookie'); return single ? [single] : []; } export async function authenticateTunnel(authenticateUrl) { const response = await fetchWithTimeout(authenticateUrl, { method: 'GET', headers: { Accept: 'text/html,application/xhtml+xml' }, redirect: 'manual', }, 20000); const setCookies = getSetCookies(response); const cookie = setCookies.map(c => c.split(';')[0]).join('; '); const location = response.headers.get('location'); if (response.status !== 307 && response.status !== 302) { throw new XyteHttpError( `Tunnel auth expected 307, got ${response.status}`, { status: response.status, body: await response.text().catch(() => null), url: authenticateUrl }, ); } if (!cookie.includes('xyte_auth=')) { throw new Error('Tunnel auth did not return xyte_auth cookie'); } return { cookie, redirectUrl: location, status: response.status, }; } export async function openAuthenticatedTunnel(deviceId, { pollSeconds = 90 } = {}) { await ensureUiSession(); const command = await uiOpenTunnel(deviceId); const status = await pollTunnelStatus(command.tunnel_status_url, { timeoutSeconds: pollSeconds, }); const auth = await authenticateTunnel(command.tunnel_authenticate_url); const redirectUrl = auth.redirectUrl || command.tunnel_redirect_url; return { command, status, cookie: auth.cookie, redirectUrl, wsUrl: redirectUrl.replace(/^http/, 'ws') + '/ws', }; } export { UI_BASE, HUB_BASE, AUTH_BASE };