collabSupport/integrations/atlas/uiSession.js
jmcqueen 5e54ea6f57 Add /atlasdiag live AZM diagnostic with source alerts and monitor auto-off.
Ports Atlas /check as /atlasdiag: Hub resolve, UI tunnel, WebSocket report
for sources/zones/accessories, low source dB warnings, and auto-disable of
the monitor speaker when left on after troubleshooting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 17:59:17 -04:00

371 lines
10 KiB
JavaScript

// 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<string, string>|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 };