'use strict'; const { canonicalizeAltaOrigin, assertSameAltaOrigin } = require('./url-policy'); const { formatAltaError } = require('./error-redaction'); const DEFAULT_TIMEOUT_MS = 10_000; const DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024; const DEFAULT_MAX_REDIRECTS = 3; const ENDPOINTS = Object.freeze({ getDevices: Object.freeze({ path: '/api/v1/devices', shape: 'array' }), getDeviceSites: Object.freeze({ path: '/api/v1/deviceSites', shape: 'array' }), getAuthInfo: Object.freeze({ path: '/api/v1/auth', shape: 'object' }), }); const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); function altaError(code, message, extra = {}) { const error = new Error(message); error.code = code; Object.assign(error, extra); return error; } async function axiosTransport(options) { // Lazy loading keeps pure/injected transport tests independent of installed // runtime dependencies while production still uses the pinned Axios package. const axios = require('axios'); return axios.request({ method: options.method, url: options.url, headers: options.headers, timeout: options.timeout, signal: options.signal, proxy: false, maxRedirects: 0, maxContentLength: options.maxResponseBytes, maxBodyLength: 0, responseType: 'arraybuffer', validateStatus: () => true, transitional: { clarifyTimeoutError: true }, }); } function normalizeHeaders(headers) { if (!headers || typeof headers !== 'object') return Object.freeze({}); const normalized = {}; for (const [key, value] of Object.entries(headers)) { normalized[String(key).toLowerCase()] = Array.isArray(value) ? value[0] : value; } return Object.freeze(normalized); } function measuredData(data) { if (Buffer.isBuffer(data) || data instanceof Uint8Array) { return { bytes: data.byteLength, raw: Buffer.from(data).toString('utf8') }; } if (typeof data === 'string') { return { bytes: Buffer.byteLength(data), raw: data }; } if (data === undefined) { throw altaError('INVALID_ALTA_RESPONSE', 'Alta transport returned no response data'); } try { const raw = JSON.stringify(data); if (raw === undefined) throw new Error('not serializable'); return { bytes: Buffer.byteLength(raw), value: data }; } catch { throw altaError('INVALID_ALTA_RESPONSE_DATA', 'Alta response data is malformed'); } } function parseData(data, maxBytes) { const measured = measuredData(data); if (measured.bytes > maxBytes) { throw altaError('ALTA_RESPONSE_TOO_LARGE', 'Alta response exceeded the size limit'); } if (Object.prototype.hasOwnProperty.call(measured, 'value')) return measured.value; try { return JSON.parse(measured.raw); } catch { throw altaError('INVALID_ALTA_RESPONSE_DATA', 'Alta response was not valid JSON'); } } function enforceResponseSize(data, headers, maxBytes) { const contentLength = headers['content-length']; if (contentLength !== undefined) { const parsedLength = Number(contentLength); if (!Number.isSafeInteger(parsedLength) || parsedLength < 0) { throw altaError('INVALID_ALTA_RESPONSE', 'Alta response had an invalid content length'); } if (parsedLength > maxBytes) { throw altaError('ALTA_RESPONSE_TOO_LARGE', 'Alta response exceeded the size limit'); } } if (data !== undefined && measuredData(data).bytes > maxBytes) { throw altaError('ALTA_RESPONSE_TOO_LARGE', 'Alta response exceeded the size limit'); } } function safeTransportError(operation, error, cookie) { const formatted = formatAltaError(operation, error, { secrets: [cookie, `va=${cookie}`] }); return altaError(formatted.code, formatted.message, { status: formatted.status, timeout: formatted.timeout, }); } class AltaClient { constructor({ sessionStore, transport = axiosTransport, timeoutMs = DEFAULT_TIMEOUT_MS, maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, maxRedirects = DEFAULT_MAX_REDIRECTS, } = {}) { if (!sessionStore || typeof sessionStore.requireSession !== 'function') { throw new TypeError('AltaClient requires a session store'); } if (typeof transport !== 'function') throw new TypeError('AltaClient transport must be a function'); if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > DEFAULT_TIMEOUT_MS) { throw new RangeError('AltaClient timeout must be between 1 and 10000ms'); } if (!Number.isInteger(maxResponseBytes) || maxResponseBytes < 1 || maxResponseBytes > DEFAULT_MAX_RESPONSE_BYTES) { throw new RangeError('Invalid Alta response size limit'); } if (!Number.isInteger(maxRedirects) || maxRedirects < 0 || maxRedirects > 5) { throw new RangeError('Invalid Alta redirect limit'); } this.sessionStore = sessionStore; this.transport = transport; this.timeoutMs = timeoutMs; this.maxResponseBytes = maxResponseBytes; this.maxRedirects = maxRedirects; } getDevices(...args) { return this.#invoke('getDevices', args); } getDeviceSites(...args) { return this.#invoke('getDeviceSites', args); } getAuthInfo(...args) { return this.#invoke('getAuthInfo', args); } async #invoke(operation, args) { if (args.length !== 0) { throw altaError('INVALID_ALTA_ARGUMENTS', 'Alta API methods do not accept renderer parameters'); } const endpoint = ENDPOINTS[operation]; const session = this.sessionStore.requireSession(); const origin = canonicalizeAltaOrigin(session.origin); const cookie = session.cookie; const deadline = Date.now() + this.timeoutMs; const controller = new AbortController(); try { const data = await this.#request(operation, `${origin}${endpoint.path}`, origin, cookie, deadline, controller, 0); if (endpoint.shape === 'array' && !Array.isArray(data)) { throw altaError('INVALID_ALTA_RESPONSE_DATA', 'Alta response did not contain an array'); } if (endpoint.shape === 'object' && (!data || typeof data !== 'object' || Array.isArray(data))) { throw altaError('INVALID_ALTA_RESPONSE_DATA', 'Alta response did not contain an object'); } return data; } catch (error) { throw safeTransportError(operation, error, cookie); } finally { controller.abort(); } } async #request(operation, url, origin, cookie, deadline, controller, redirectCount) { assertSameAltaOrigin(url, origin); const remaining = deadline - Date.now(); if (remaining <= 0) throw altaError('ALTA_TIMEOUT', 'Alta request timed out', { timeout: true }); let timer; const timeoutPromise = new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(altaError('ALTA_TIMEOUT', 'Alta request timed out', { timeout: true })); }, remaining); }); let response; try { response = await Promise.race([ Promise.resolve(this.transport(Object.freeze({ method: 'GET', url, headers: Object.freeze({ Cookie: `va=${cookie}`, Accept: 'application/json' }), timeout: remaining, signal: controller.signal, proxy: false, maxRedirects: 0, maxResponseBytes: this.maxResponseBytes, }))), timeoutPromise, ]); } finally { clearTimeout(timer); } if (!response || typeof response !== 'object' || !Number.isInteger(response.status)) { throw altaError('INVALID_ALTA_RESPONSE', 'Alta transport returned an invalid response'); } const headers = normalizeHeaders(response.headers); // Bound every response, including redirects and errors, before acting on it. enforceResponseSize(response.data, headers, this.maxResponseBytes); if (REDIRECT_STATUSES.has(response.status)) { if (typeof headers.location !== 'string' || headers.location.length === 0) { throw altaError('UNSAFE_ALTA_REDIRECT', 'Alta redirect did not contain a safe location'); } if (redirectCount >= this.maxRedirects) { throw altaError('TOO_MANY_ALTA_REDIRECTS', 'Alta response exceeded the redirect limit'); } let target; try { target = new URL(headers.location, url); assertSameAltaOrigin(target.href, origin); } catch { throw altaError('UNSAFE_ALTA_REDIRECT', 'Alta redirect changed the validated origin'); } return this.#request(operation, target.href, origin, cookie, deadline, controller, redirectCount + 1); } if (response.status < 200 || response.status > 299) { throw altaError('ALTA_HTTP_ERROR', 'Alta request returned an error status', { status: response.status }); } return parseData(response.data, this.maxResponseBytes); } } function createAltaClient(options) { return new AltaClient(options); } module.exports = { AltaClient, DEFAULT_MAX_REDIRECTS, DEFAULT_MAX_RESPONSE_BYTES, DEFAULT_TIMEOUT_MS, ENDPOINTS, axiosTransport, createAltaClient, };