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>
371 lines
10 KiB
JavaScript
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 };
|