feat: add bounded large-deployment discovery

This commit is contained in:
2026-08-20 01:05:46 +00:00
parent 4a10e6051e
commit 6a8a4cf95a
8 changed files with 449 additions and 24 deletions
+39 -12
View File
@@ -3,13 +3,24 @@
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_TIMEOUT_MS = 30_000;
const DEVICE_MAX_RESPONSE_BYTES = 32 * 1024 * 1024;
const HIERARCHY_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
const AUTH_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
const DEFAULT_MAX_RESPONSE_BYTES = DEVICE_MAX_RESPONSE_BYTES;
const MAX_ARRAY_OBJECTS = 10_000;
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' }),
getDevices: Object.freeze({
path: '/api/v1/devices', shape: 'array', maxResponseBytes: DEVICE_MAX_RESPONSE_BYTES, maxObjects: MAX_ARRAY_OBJECTS,
}),
getDeviceSites: Object.freeze({
path: '/api/v1/deviceSites', shape: 'array', maxResponseBytes: HIERARCHY_MAX_RESPONSE_BYTES, maxObjects: MAX_ARRAY_OBJECTS,
}),
getDeviceGroups: Object.freeze({
path: '/api/v1/deviceGroups', shape: 'array', maxResponseBytes: HIERARCHY_MAX_RESPONSE_BYTES, maxObjects: MAX_ARRAY_OBJECTS,
}),
getAuthInfo: Object.freeze({ path: '/api/v1/auth', shape: 'object', maxResponseBytes: AUTH_MAX_RESPONSE_BYTES }),
});
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
@@ -118,7 +129,7 @@ class AltaClient {
}
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');
throw new RangeError('AltaClient timeout must be between 1 and 30000ms');
}
if (!Number.isInteger(maxResponseBytes) || maxResponseBytes < 1 || maxResponseBytes > DEFAULT_MAX_RESPONSE_BYTES) {
throw new RangeError('Invalid Alta response size limit');
@@ -141,6 +152,10 @@ class AltaClient {
return this.#invoke('getDeviceSites', args);
}
getDeviceGroups(...args) {
return this.#invoke('getDeviceGroups', args);
}
getAuthInfo(...args) {
return this.#invoke('getAuthInfo', args);
}
@@ -150,6 +165,7 @@ class AltaClient {
throw altaError('INVALID_ALTA_ARGUMENTS', 'Alta API methods do not accept renderer parameters');
}
const endpoint = ENDPOINTS[operation];
const maxResponseBytes = Math.min(this.maxResponseBytes, endpoint.maxResponseBytes);
const session = this.sessionStore.requireSession();
const origin = canonicalizeAltaOrigin(session.origin);
const cookie = session.cookie;
@@ -157,10 +173,15 @@ class AltaClient {
const controller = new AbortController();
try {
const data = await this.#request(operation, `${origin}${endpoint.path}`, origin, cookie, deadline, controller, 0);
const data = await this.#request(
operation, `${origin}${endpoint.path}`, origin, cookie, deadline, controller, 0, maxResponseBytes,
);
if (endpoint.shape === 'array' && !Array.isArray(data)) {
throw altaError('INVALID_ALTA_RESPONSE_DATA', 'Alta response did not contain an array');
}
if (endpoint.shape === 'array' && data.length > endpoint.maxObjects) {
throw altaError('ALTA_RESPONSE_TOO_MANY_OBJECTS', 'Alta response exceeded the object limit');
}
if (endpoint.shape === 'object' && (!data || typeof data !== 'object' || Array.isArray(data))) {
throw altaError('INVALID_ALTA_RESPONSE_DATA', 'Alta response did not contain an object');
}
@@ -172,7 +193,7 @@ class AltaClient {
}
}
async #request(operation, url, origin, cookie, deadline, controller, redirectCount) {
async #request(operation, url, origin, cookie, deadline, controller, redirectCount, maxResponseBytes) {
assertSameAltaOrigin(url, origin);
const remaining = deadline - Date.now();
if (remaining <= 0) throw altaError('ALTA_TIMEOUT', 'Alta request timed out', { timeout: true });
@@ -196,7 +217,7 @@ class AltaClient {
signal: controller.signal,
proxy: false,
maxRedirects: 0,
maxResponseBytes: this.maxResponseBytes,
maxResponseBytes,
}))),
timeoutPromise,
]);
@@ -209,7 +230,7 @@ class AltaClient {
}
const headers = normalizeHeaders(response.headers);
// Bound every response, including redirects and errors, before acting on it.
enforceResponseSize(response.data, headers, this.maxResponseBytes);
enforceResponseSize(response.data, headers, maxResponseBytes);
if (REDIRECT_STATUSES.has(response.status)) {
if (typeof headers.location !== 'string' || headers.location.length === 0) {
@@ -225,14 +246,16 @@ class AltaClient {
} catch {
throw altaError('UNSAFE_ALTA_REDIRECT', 'Alta redirect changed the validated origin');
}
return this.#request(operation, target.href, origin, cookie, deadline, controller, redirectCount + 1);
return this.#request(
operation, target.href, origin, cookie, deadline, controller, redirectCount + 1, maxResponseBytes,
);
}
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);
return parseData(response.data, maxResponseBytes);
}
}
@@ -242,10 +265,14 @@ function createAltaClient(options) {
module.exports = {
AltaClient,
AUTH_MAX_RESPONSE_BYTES,
DEFAULT_MAX_REDIRECTS,
DEFAULT_MAX_RESPONSE_BYTES,
DEFAULT_TIMEOUT_MS,
DEVICE_MAX_RESPONSE_BYTES,
ENDPOINTS,
HIERARCHY_MAX_RESPONSE_BYTES,
MAX_ARRAY_OBJECTS,
axiosTransport,
createAltaClient,
};
+127
View File
@@ -0,0 +1,127 @@
'use strict';
const { validateDeviceId } = require('./proxy-launch');
const MAX_PROJECTED_PAYLOAD_BYTES = 8 * 1024 * 1024;
function projectionError(code, message) {
const error = new Error(message);
error.code = code;
return error;
}
function nullableString(value) {
return typeof value === 'string' ? value : null;
}
function nullableId(value) {
return typeof value === 'string' && value.length > 0 ? value : null;
}
function nullableBoolean(value) {
return typeof value === 'boolean' ? value : null;
}
function projectDevice(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
let id;
try {
id = validateDeviceId(raw.guid);
} catch {
return null;
}
return {
id,
name: nullableString(raw.name),
type: nullableString(raw.type),
model: nullableString(raw.model),
address: nullableString(raw.address),
siteId: nullableId(raw.server_group_id),
deviceGroupId: nullableId(raw.device_group_id),
displayStatus: nullableString(raw.live && raw.live.display_status),
localStorage: nullableBoolean(raw.capabilities && raw.capabilities.localStorage),
};
}
function projectSite(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const id = nullableId(raw.id);
if (!id) return null;
return {
id,
name: nullableString(raw.name),
pendingDeletionStart: nullableString(raw.pending_deletion_start),
};
}
function projectGroup(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const id = nullableId(raw.id);
if (!id) return null;
return {
id,
name: nullableString(raw.name),
parentId: nullableId(raw.parent_id),
pendingDeletionStart: nullableString(raw.pending_deletion_start),
};
}
function projectArray(values, projector) {
const projected = [];
let invalid = 0;
for (const value of Array.isArray(values) ? values : []) {
const item = projector(value);
if (item) projected.push(item);
else invalid += 1;
}
return { projected, invalid };
}
function safeMetadataError(value) {
return typeof value === 'string' && value.length > 0 ? value.slice(0, 512) : null;
}
function projectDeviceHierarchy({ devices = [], sites = [], groups = [], metadataErrors = {} } = {}) {
if (!Array.isArray(devices) || !Array.isArray(sites) || !Array.isArray(groups)) {
throw new TypeError('Hierarchy projection requires arrays');
}
const deviceProjection = projectArray(devices, projectDevice);
const siteProjection = projectArray(sites, projectSite);
const groupProjection = projectArray(groups, projectGroup);
const payload = {
devices: deviceProjection.projected,
sites: siteProjection.projected,
groups: groupProjection.projected,
diagnostics: {
devices: {
received: devices.length,
eligible: deviceProjection.projected.length,
invalid: deviceProjection.invalid,
},
sites: {
received: sites.length,
projected: siteProjection.projected.length,
invalid: siteProjection.invalid,
error: safeMetadataError(metadataErrors.sites),
},
groups: {
received: groups.length,
projected: groupProjection.projected.length,
invalid: groupProjection.invalid,
error: safeMetadataError(metadataErrors.groups),
},
},
};
if (Buffer.byteLength(JSON.stringify(payload)) > MAX_PROJECTED_PAYLOAD_BYTES) {
throw projectionError('PROJECTED_PAYLOAD_TOO_LARGE', 'Projected Alta hierarchy exceeded the IPC size limit');
}
return payload;
}
module.exports = {
MAX_PROJECTED_PAYLOAD_BYTES,
projectDevice,
projectDeviceHierarchy,
projectGroup,
projectSite,
};
+74 -8
View File
@@ -10,14 +10,18 @@ const {
readJsonBody,
} = require('./bridge-auth');
const { RELEASES_PAGE_URL } = require('./update-policy');
const { projectDeviceHierarchy, projectGroup, projectSite } = require('./device-projection');
const { validateDeviceId, validateUsername } = require('./proxy-launch');
const DEFAULT_DISCOVERY_TIMEOUT_MS = 60_000;
const PAIRING_ENVELOPE_FILENAME = 'bridge-pairing.json';
function safeErrorMessage(error, fallback) {
const allowed = new Set([
'NO_ALTA_SESSION', 'INVALID_ALTA_ARGUMENTS', 'ALTA_TIMEOUT', 'ALTA_HTTP_ERROR',
'INVALID_ALTA_RESPONSE', 'INVALID_ALTA_RESPONSE_DATA', 'ALTA_RESPONSE_TOO_LARGE',
'ALTA_RESPONSE_TOO_MANY_OBJECTS', 'PROJECTED_PAYLOAD_TOO_LARGE',
'UNSAFE_ALTA_REDIRECT', 'TOO_MANY_ALTA_REDIRECTS', 'HELPER_NOT_FOUND',
'UNSUPPORTED_PLATFORM', 'INVALID_DEVICE_ID', 'INVALID_USERNAME', 'SPAWN_FAILED',
]);
@@ -196,13 +200,19 @@ class AppRuntime {
checkForUpdate,
currentVersion,
openExternal,
discoveryTimeoutMs = DEFAULT_DISCOVERY_TIMEOUT_MS,
} = {}) {
if (!Number.isInteger(discoveryTimeoutMs) || discoveryTimeoutMs < 1 ||
discoveryTimeoutMs > DEFAULT_DISCOVERY_TIMEOUT_MS) {
throw new RangeError('AppRuntime discovery timeout must be between 1 and 60000ms');
}
this.sessionStore = sessionStore;
this.altaClient = altaClient;
this.proxyManager = proxyManager;
this.checkForUpdatePolicy = checkForUpdate;
this.currentVersion = currentVersion;
this.openExternal = openExternal;
this.discoveryTimeoutMs = discoveryTimeoutMs;
this.allowedDeviceIds = new Set();
this.proxyByDevice = new Map();
}
@@ -223,15 +233,17 @@ class AppRuntime {
return tracked.filter((proxy) => this.proxyByDevice.get(proxy.deviceId) === proxy.processId);
}
_setAllowedDevices(devices) {
this.allowedDeviceIds = new Set(devices.map((device) => device.id));
}
async getDevices() {
try {
const devices = await this.altaClient.getDevices();
this.allowedDeviceIds = new Set();
for (const device of devices) {
const candidate = device && (device.guid || device.id);
try { this.allowedDeviceIds.add(validateDeviceId(candidate)); } catch {}
}
return { success: true, devices };
const hierarchy = projectDeviceHierarchy({
devices: await this.altaClient.getDevices(), sites: [], groups: [],
});
this._setAllowedDevices(hierarchy.devices);
return { success: true, devices: hierarchy.devices };
} catch (error) {
return { success: false, devices: [], message: safeErrorMessage(error, 'Failed to get devices') };
}
@@ -239,12 +251,66 @@ class AppRuntime {
async getDeviceSites() {
try {
return { success: true, sites: await this.altaClient.getDeviceSites() };
const sites = (await this.altaClient.getDeviceSites()).map(projectSite).filter(Boolean);
return { success: true, sites };
} catch (error) {
return { success: false, sites: [], message: safeErrorMessage(error, 'Failed to get device sites') };
}
}
async getDeviceGroups() {
try {
const groups = (await this.altaClient.getDeviceGroups()).map(projectGroup).filter(Boolean);
return { success: true, groups };
} catch (error) {
return { success: false, groups: [], message: safeErrorMessage(error, 'Failed to get device groups') };
}
}
async getDeviceHierarchy() {
let timer;
try {
const discovery = Promise.allSettled([
this.altaClient.getDevices(),
this.altaClient.getDeviceSites(),
this.altaClient.getDeviceGroups(),
]);
const deadline = new Promise((_, reject) => {
timer = setTimeout(() => {
const error = new Error('Alta device discovery timed out');
error.code = 'DISCOVERY_TIMEOUT';
reject(error);
}, this.discoveryTimeoutMs);
});
const [deviceResult, siteResult, groupResult] = await Promise.race([discovery, deadline]);
if (deviceResult.status === 'rejected') throw deviceResult.reason;
const metadataErrors = {};
if (siteResult.status === 'rejected') {
metadataErrors.sites = 'Device sites unavailable';
}
if (groupResult.status === 'rejected') {
metadataErrors.groups = 'Device groups unavailable';
}
const hierarchy = projectDeviceHierarchy({
devices: deviceResult.value,
sites: siteResult.status === 'fulfilled' ? siteResult.value : [],
groups: groupResult.status === 'fulfilled' ? groupResult.value : [],
metadataErrors,
});
this._setAllowedDevices(hierarchy.devices);
return { success: true, hierarchy };
} catch (error) {
this.allowedDeviceIds.clear();
const message = error && error.code === 'DISCOVERY_TIMEOUT'
? 'Alta device discovery timed out'
: safeErrorMessage(error, 'Failed to discover Alta devices');
return { success: false, hierarchy: { devices: [], sites: [], groups: [] }, message };
} finally {
clearTimeout(timer);
}
}
async getAuthInfo() {
try {
return { success: true, authInfo: await this.altaClient.getAuthInfo() };