/**
* DXP REST API Client
* Direct REST API calls to Optimizely DXP without PowerShell dependency
* Implements HMAC-SHA256 authentication as used by EpiCloud PowerShell module
* Part of Jaxon Digital Optimizely DXP MCP Server
*/
const crypto = require('crypto');
const https = require('https');
const { URL } = require('url');
const OutputLogger = require('./output-logger');
const RateLimiter = require('./rate-limiter');
class DXPRestClient {
/**
* Default API endpoint
*/
static DEFAULT_ENDPOINT = 'https://paasportal.episerver.net/api/v1.0/';
/**
* Shared rate limiter instance for all API calls
* Tracks rate limits per project ID
*/
static rateLimiter = new RateLimiter({
maxRequestsPerMinute: 30, // Conservative limit for Optimizely API
maxRequestsPerHour: 500, // Hourly limit
burstAllowance: 5, // Allow small bursts
debug: process.env.DEBUG === 'true'
});
/**
* Generate HMAC-SHA256 signature for API request
* Based on SetApiAuthorizationHeader from EpiCloud.psm1 (lines 533-608)
*
* @param {string} clientKey - API client key
* @param {string} clientSecret - API client secret (base64 encoded)
* @param {string} method - HTTP method (GET, POST, etc.)
* @param {string} pathAndQuery - URL path and query string
* @param {string} body - Request body (JSON string)
* @returns {string} Authorization header value
*/
static generateAuthHeader(clientKey, clientSecret, method, pathAndQuery, body = '') {
try {
// Generate timestamp (Unix milliseconds)
const timestamp = Date.now().toString();
// Generate nonce (UUID without hyphens)
const nonce = crypto.randomUUID().replace(/-/g, '');
// Calculate MD5 hash of body
const bodyBytes = Buffer.from(body, 'utf8');
const bodyHash = crypto.createHash('md5').update(bodyBytes).digest('base64');
// Combine message components (line 596 in EpiCloud.psm1)
// Format: {ClientKey}{Method}{Path}{Timestamp}{Nonce}{BodyHash}
const message = `${clientKey}${method.toUpperCase()}${pathAndQuery}${timestamp}${nonce}${bodyHash}`;
// Create HMAC-SHA256 signature
const secretBuffer = Buffer.from(clientSecret, 'base64');
const signature = crypto
.createHmac('sha256', secretBuffer)
.update(message, 'utf8')
.digest('base64');
// Format authorization header (line 604 in EpiCloud.psm1)
// Format: "epi-hmac {ClientKey}:{Timestamp}:{Nonce}:{Signature}"
return `epi-hmac ${clientKey}:${timestamp}:${nonce}:${signature}`;
} catch (error) {
throw new Error(`Failed to generate auth header: ${error.message}`);
}
}
/**
* Make authenticated API request with rate limiting and automatic retry
* Based on InvokeApiRequest from EpiCloud.psm1 (lines 369-440)
*
* @param {string} clientKey - API client key
* @param {string} clientSecret - API client secret
* @param {string} uriEnding - API endpoint path
* @param {string} method - HTTP method (default: GET)
* @param {Object} payload - Request payload (optional)
* @param {Object|string} options - Options object with apiUrl, projectId, or legacy baseUrl string
* @returns {Promise<Object>} API response
*/
static async makeRequest(clientKey, clientSecret, uriEnding, method = 'GET', payload = null, options = null) {
// Extract project ID for rate limiting (from URI or options)
let projectId = 'unknown';
if (options && typeof options === 'object' && options.projectId) {
projectId = options.projectId;
} else {
// Try to extract from URI (format: projects/{projectId}/...)
const match = uriEnding.match(/^projects\/([^\/]+)/);
if (match) {
projectId = match[1];
}
}
// Configure retry parameters
const maxRetries = (options && typeof options === 'object' && options.maxRetries) || 3;
const retryDelay = (options && typeof options === 'object' && options.retryDelay) || 1000;
let lastError = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
// Check rate limit BEFORE making request
const rateCheck = this.rateLimiter.checkRateLimit(projectId, 'api_call');
if (!rateCheck.allowed) {
const waitMs = rateCheck.waitTime || 1000;
OutputLogger.debug(`Rate limit: waiting ${waitMs}ms (${rateCheck.reason}) before retry ${attempt}/${maxRetries}`);
// Wait for rate limit to clear
await this._sleep(waitMs);
// Try again after waiting
continue;
}
// Handle legacy baseUrl parameter (for backward compatibility)
let apiUrl = null;
if (typeof options === 'string') {
// Legacy: options was baseUrl string
apiUrl = options;
} else if (options && typeof options === 'object') {
// New: options object with apiUrl
apiUrl = options.apiUrl;
}
// Priority order for API endpoint:
// 1. Directly passed apiUrl (via options)
// 2. Global environment variable OPTIMIZELY_API_URL
// 3. DEFAULT_ENDPOINT
const endpoint = apiUrl ||
process.env.OPTIMIZELY_API_URL ||
this.DEFAULT_ENDPOINT;
const fullUrl = new URL(uriEnding, endpoint);
// Prepare request body
let body = '';
if (payload) {
body = JSON.stringify(payload);
}
// Generate authentication header
const pathAndQuery = fullUrl.pathname + fullUrl.search;
const authHeader = this.generateAuthHeader(clientKey, clientSecret, method, pathAndQuery, body);
// Extract timeout from options (default: 120s for most operations, can be overridden)
const timeout = (options && typeof options === 'object' && options.timeout) || 120000;
// Prepare request options
const requestOptions = {
hostname: fullUrl.hostname,
port: fullUrl.port || 443,
path: pathAndQuery,
method: method,
headers: {
'Authorization': authHeader,
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'Jaxon-Optimizely-DXP-MCP/3.0'
},
timeout: timeout // Configurable timeout (default: 120s)
};
if (body) {
requestOptions.headers['Content-Length'] = Buffer.byteLength(body);
}
OutputLogger.debug(`API Request: ${method} ${fullUrl.href} (attempt ${attempt}/${maxRetries})`);
// Make the request
const result = await this._executeRequest(requestOptions, body);
// Success - record the request and return
this.rateLimiter.recordRequest(projectId, 'api_call');
return result;
} catch (error) {
lastError = error;
// Check if it's a 429 rate limit error
if (error.statusCode === 429) {
// Parse Retry-After header from response
const retryAfter = this._parseRetryAfter(error.retryAfter) || retryDelay * attempt;
// Record the rate limit in our tracker
this.rateLimiter.recordRateLimit(projectId, { retryAfter });
// If we have more attempts, wait and retry
if (attempt < maxRetries) {
const waitMs = Math.min(retryAfter, 30000); // Cap at 30 seconds
OutputLogger.debug(`HTTP 429: Rate limit exceeded, waiting ${Math.round(waitMs/1000)}s before retry ${attempt + 1}/${maxRetries}`);
await this._sleep(waitMs);
continue;
}
// No more retries - throw with helpful message
throw new Error(`Rate limit exceeded after ${maxRetries} attempts. Please retry after ${Math.round(retryAfter/1000)} seconds.`);
}
// Check for other retryable errors (503, 502, network errors)
if (this._isRetryableError(error) && attempt < maxRetries) {
const waitMs = retryDelay * Math.pow(2, attempt - 1); // Exponential backoff
OutputLogger.debug(`Retryable error (${error.message}), waiting ${waitMs}ms before retry ${attempt + 1}/${maxRetries}`);
await this._sleep(waitMs);
continue;
}
// Not retryable or out of retries - throw the error
throw new Error(`API request failed: ${error.message}`);
}
}
// Should never reach here, but just in case
throw new Error(`API request failed after ${maxRetries} attempts: ${lastError?.message || 'Unknown error'}`);
}
/**
* Execute HTTP request and parse response
*
* @private
* @param {Object} options - Request options
* @param {string} body - Request body
* @returns {Promise<Object>} Parsed response
*/
static _executeRequest(options, body) {
return new Promise((resolve, reject) => {
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
// Check HTTP status
if (res.statusCode < 200 || res.statusCode >= 300) {
// Try to parse error response
let errorMessage = `HTTP ${res.statusCode}`;
try {
const errorData = JSON.parse(data);
if (errorData.errors) {
errorMessage += `: ${errorData.errors.join(', ')}`;
} else if (errorData.message) {
errorMessage += `: ${errorData.message}`;
}
} catch {
errorMessage += `: ${data.substring(0, 200)}`;
}
const error = new Error(errorMessage);
error.statusCode = res.statusCode;
error.response = data;
// Capture Retry-After header for rate limiting
error.retryAfter = res.headers['retry-after'];
reject(error);
return;
}
// Parse successful response
if (data) {
const parsed = JSON.parse(data);
// Check API success flag (from EpiCloud.psm1 line 430-432)
if (parsed.success === false) {
const errorMsg = parsed.errors ? parsed.errors.join(', ') : 'Unknown error';
reject(new Error(`API call failed: ${errorMsg}`));
return;
}
// Return result if present, otherwise full response
resolve(parsed.result || parsed);
} else {
resolve({});
}
} catch (error) {
reject(new Error(`Failed to parse response: ${error.message}`));
}
});
});
req.on('error', (error) => {
reject(new Error(`Request error: ${error.message}`));
});
req.on('timeout', () => {
req.destroy();
reject(new Error('Request timeout'));
});
if (body) {
req.write(body);
}
req.end();
});
}
/**
* Check if error is retryable (network/temporary errors)
* @private
* @param {Error} error - Error object
* @returns {boolean} True if retryable
*/
static _isRetryableError(error) {
if (!error) return false;
// Check status codes
if (error.statusCode === 502 || // Bad Gateway
error.statusCode === 503 || // Service Unavailable
error.statusCode === 504) { // Gateway Timeout
return true;
}
// Check error codes
const retryableCodes = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ENETUNREACH'];
if (error.code && retryableCodes.includes(error.code)) {
return true;
}
// Check message patterns
const message = (error.message || '').toLowerCase();
if (message.includes('timeout') ||
message.includes('network') ||
message.includes('connection')) {
return true;
}
return false;
}
/**
* Parse Retry-After header
* @private
* @param {string|number} retryAfter - Retry-After header value
* @returns {number} Milliseconds to wait
*/
static _parseRetryAfter(retryAfter) {
if (!retryAfter) return null;
// If it's a number, treat as seconds
if (typeof retryAfter === 'number') {
return retryAfter * 1000;
}
// If it's a string, could be seconds or HTTP date
if (typeof retryAfter === 'string') {
const seconds = parseInt(retryAfter, 10);
if (!isNaN(seconds)) {
return seconds * 1000;
}
// Try parsing as date
const date = new Date(retryAfter);
if (!isNaN(date.getTime())) {
return Math.max(0, date.getTime() - Date.now());
}
}
return null;
}
/**
* Sleep helper
* @private
* @param {number} ms - Milliseconds to sleep
* @returns {Promise<void>}
*/
static _sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Test environment access by listing storage containers
* Equivalent to: Get-EpiStorageContainer -ProjectId $id -Environment $env
*
* @param {string} projectId - Project ID
* @param {string} clientKey - API client key
* @param {string} clientSecret - API client secret
* @param {string} environment - Environment name (Integration, Preproduction, Production)
* @param {Object} options - Options (apiUrl, etc.)
* @returns {Promise<boolean>} True if access granted
*/
static async testEnvironmentAccess(projectId, clientKey, clientSecret, environment, options = {}) {
try {
const uriEnding = `projects/${projectId}/environments/${environment}/storagecontainers`;
const result = await this.makeRequest(clientKey, clientSecret, uriEnding, 'GET', null, options);
// If we got a result, we have access
return result && (Array.isArray(result) ? result.length >= 0 : true);
} catch (error) {
// Check for access denied errors
if (error.statusCode === 401 || error.statusCode === 403) {
return false;
}
// Check message for access denied
const errorMsg = error.message.toLowerCase();
if (errorMsg.includes('unauthorized') ||
errorMsg.includes('forbidden') ||
errorMsg.includes('access denied')) {
return false;
}
// Any other error, assume no access
OutputLogger.debug(`Environment access test error (${environment}): ${error.message}`);
return false;
}
}
/**
* Get list of deployments
* Equivalent to: Get-EpiDeployment -ProjectId $id
*
* @param {string} projectId - Project ID
* @param {string} clientKey - API client key
* @param {string} clientSecret - API client secret
* @param {string} deploymentId - Optional deployment ID
* @param {Object} options - Options (apiUrl, etc.)
* @returns {Promise<Object>} Deployment(s)
*/
static async getDeployments(projectId, clientKey, clientSecret, deploymentId = null, options = {}) {
const uriEnding = deploymentId
? `projects/${projectId}/deployments/${deploymentId}`
: `projects/${projectId}/deployments`;
return await this.makeRequest(clientKey, clientSecret, uriEnding, 'GET', null, options);
}
/**
* Get storage containers for an environment
* Equivalent to: Get-EpiStorageContainer -ProjectId $id -Environment $env
*
* @param {string} projectId - Project ID
* @param {string} clientKey - API client key
* @param {string} clientSecret - API client secret
* @param {string} environment - Environment name
* @param {boolean} writable - Only return writable containers
* @param {Object} options - Options (apiUrl, etc.)
* @returns {Promise<Array>} List of storage containers
*/
static async getStorageContainers(projectId, clientKey, clientSecret, environment, writable = false, options = {}) {
const uriEnding = `projects/${projectId}/environments/${environment}/storagecontainers${writable ? '?writable=true' : ''}`;
return await this.makeRequest(clientKey, clientSecret, uriEnding, 'GET', null, options);
}
/**
* Start a deployment
* Equivalent to: Start-EpiDeployment
*
* @param {string} projectId - Project ID
* @param {string} clientKey - API client key
* @param {string} clientSecret - API client secret
* @param {Object} deploymentParams - Deployment parameters
* @param {Object} options - Options (apiUrl, etc.)
* @returns {Promise<Object>} Deployment details
*/
static async startDeployment(projectId, clientKey, clientSecret, deploymentParams, options = {}) {
const uriEnding = `projects/${projectId}/deployments`;
return await this.makeRequest(clientKey, clientSecret, uriEnding, 'POST', deploymentParams, options);
}
/**
* Complete a deployment
* Equivalent to: Complete-EpiDeployment
*
* @param {string} projectId - Project ID
* @param {string} clientKey - API client key
* @param {string} clientSecret - API client secret
* @param {string} deploymentId - Deployment ID to complete
* @param {Object} options - Options (apiUrl, etc.)
* @returns {Promise<Object>} Deployment details
*/
static async completeDeployment(projectId, clientKey, clientSecret, deploymentId, options = {}) {
const uriEnding = `projects/${projectId}/deployments/${deploymentId}/complete`;
return await this.makeRequest(clientKey, clientSecret, uriEnding, 'POST', {}, options);
}
/**
* Reset a deployment
* Equivalent to: Reset-EpiDeployment
*
* @param {string} projectId - Project ID
* @param {string} clientKey - API client key
* @param {string} clientSecret - API client secret
* @param {string} deploymentId - Deployment ID to reset
* @param {Object} resetOptions - Reset options (RollbackDatabase, ValidateBeforeSwap, Complete)
* @param {Object} options - Options (apiUrl, etc.)
* @returns {Promise<Object>} Deployment details
*/
static async resetDeployment(projectId, clientKey, clientSecret, deploymentId, resetOptions = {}, options = {}) {
const uriEnding = `projects/${projectId}/deployments/${deploymentId}/reset`;
return await this.makeRequest(clientKey, clientSecret, uriEnding, 'POST', resetOptions, options);
}
/**
* Get package upload location (SAS URL)
* Equivalent to: Get-EpiDeploymentPackageLocation
*
* @param {string} projectId - Project ID
* @param {string} clientKey - API client key
* @param {string} clientSecret - API client secret
* @param {Object} options - Options (apiUrl, etc.)
* @returns {Promise<string>} SAS URL for package upload
*/
static async getPackageLocation(projectId, clientKey, clientSecret, options = {}) {
const uriEnding = `projects/${projectId}/packages/location`;
const result = await this.makeRequest(clientKey, clientSecret, uriEnding, 'GET', null, options);
// API returns { location: "sas-url" } - return just the URL
return result.location || result;
}
/**
* Get SAS link for storage container
* Equivalent to: Get-EpiStorageContainerSasLink
*
* @param {string} projectId - Project ID
* @param {string} clientKey - API client key
* @param {string} clientSecret - API client secret
* @param {string} environment - Environment name
* @param {string} containerName - Storage container name
* @param {Object} sasOptions - SAS link options (RetentionHours, Writable)
* @param {Object} options - Options (apiUrl, etc.)
* @returns {Promise<Object>} SAS link details
*/
static async getContainerSasLink(projectId, clientKey, clientSecret, environment, containerName, sasOptions = {}, options = {}) {
const uriEnding = `projects/${projectId}/environments/${environment}/storagecontainers/${containerName}/saslink`;
const payload = {
RetentionHours: sasOptions.retentionHours || sasOptions.RetentionHours || 24,
Writable: sasOptions.writable || sasOptions.Writable || false
};
return await this.makeRequest(clientKey, clientSecret, uriEnding, 'POST', payload, options);
}
/**
* Start database export
* Equivalent to: Start-EpiDatabaseExport
*
* @param {string} projectId - Project ID
* @param {string} clientKey - API client key
* @param {string} clientSecret - API client secret
* @param {string} environment - Environment name
* @param {string} databaseName - Database name (epicms or epicommerce)
* @param {number} retentionHours - Retention hours for export (default: 24)
* @param {Object} options - Options (apiUrl, etc.)
* @returns {Promise<Object>} Export details with ID
*/
static async startDatabaseExport(projectId, clientKey, clientSecret, environment, databaseName, retentionHours = 24, options = {}) {
const uriEnding = `projects/${projectId}/environments/${environment}/databases/${databaseName}/exports`;
const payload = { RetentionHours: retentionHours };
return await this.makeRequest(clientKey, clientSecret, uriEnding, 'POST', payload, options);
}
/**
* Get database export status
* Equivalent to: Get-EpiDatabaseExport
*
* @param {string} projectId - Project ID
* @param {string} clientKey - API client key
* @param {string} clientSecret - API client secret
* @param {string} environment - Environment name
* @param {string} databaseName - Database name
* @param {string} exportId - Export ID
* @param {Object} options - Options (apiUrl, etc.)
* @returns {Promise<Object>} Export status and download URL
*/
static async getDatabaseExportStatus(projectId, clientKey, clientSecret, environment, databaseName, exportId, options = {}) {
const uriEnding = `projects/${projectId}/environments/${environment}/databases/${databaseName}/exports/${exportId}`;
return await this.makeRequest(clientKey, clientSecret, uriEnding, 'GET', null, options);
}
}
module.exports = DXPRestClient;