252 lines
8.8 KiB
JavaScript
252 lines
8.8 KiB
JavaScript
'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,
|
|
};
|