@lanonasis/cli
Version:
Professional CLI for LanOnasis Memory as a Service (MaaS) with MCP support, seamless inline editing, and enterprise-grade security
1,506 lines âĸ 66.6 kB
JavaScript
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { jwtDecode } from 'jwt-decode';
import { randomUUID } from 'crypto';
import { ApiKeyStorage } from '@lanonasis/oauth-client';
import axios from 'axios';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const packageJson = require('../../package.json');
export class CLIConfig {
configDir;
configPath;
config = {};
lockFile;
static CONFIG_VERSION = '1.0.0';
authCheckCache = null;
AUTH_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
apiKeyStorage;
vendorKeyCache;
isLegacyHashedCredential(value) {
return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value.trim());
}
getLegacyHashedVendorKeyReason() {
return 'Stored vendor key is in legacy hashed format. Run "lanonasis auth login --vendor-key <your-key>" to refresh secure storage.';
}
normalizeOptionalString(value) {
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
if (value === null || value === undefined) {
return undefined;
}
return String(value);
}
extractOrganizationId(source) {
return this.normalizeOptionalString(source.organization_id)
?? this.normalizeOptionalString(source.organizationId);
}
buildUserProfile(source, existing) {
const email = this.normalizeOptionalString(source.email) ?? existing?.email ?? '';
const organization_id = this.extractOrganizationId(source) ?? existing?.organization_id ?? '';
const role = this.normalizeOptionalString(source.role) ?? existing?.role ?? '';
const plan = this.normalizeOptionalString(source.plan) ?? existing?.plan ?? '';
if (!email && !organization_id && !role && !plan) {
return existing;
}
return {
email,
organization_id,
role,
plan
};
}
constructor() {
this.configDir = path.join(os.homedir(), '.maas');
this.configPath = path.join(this.configDir, 'config.json');
this.lockFile = path.join(this.configDir, 'config.lock');
}
getApiKeyStorage() {
if (!this.apiKeyStorage) {
this.apiKeyStorage = new ApiKeyStorage();
}
return this.apiKeyStorage;
}
/**
* Overrides the configuration storage directory. Primarily used for tests.
*/
setConfigDirectory(configDir) {
this.configDir = configDir;
this.configPath = path.join(configDir, 'config.json');
this.lockFile = path.join(configDir, 'config.lock');
this.vendorKeyCache = undefined;
}
/**
* Exposes the current config path for tests and diagnostics.
*/
getConfigPath() {
return this.configPath;
}
async init() {
try {
await fs.mkdir(this.configDir, { recursive: true });
await this.load();
}
catch {
// Config doesn't exist yet, that's ok
}
}
async load() {
// Reset in-memory cache; disk state may have changed
this.vendorKeyCache = undefined;
try {
const data = await fs.readFile(this.configPath, 'utf-8');
this.config = JSON.parse(data);
// Handle version migration if needed
await this.migrateConfigIfNeeded();
}
catch {
this.config = {};
// Set version for new config
this.config.version = CLIConfig.CONFIG_VERSION;
}
}
async migrateConfigIfNeeded() {
const currentVersion = this.config.version;
if (!currentVersion) {
// Legacy config without version, migrate to current version
this.config.version = CLIConfig.CONFIG_VERSION;
// Perform any necessary migrations for legacy configs
// For now, just ensure the version is set
await this.save();
}
else if (currentVersion !== CLIConfig.CONFIG_VERSION) {
// Future version migrations would go here
// For now, just update the version
this.config.version = CLIConfig.CONFIG_VERSION;
await this.save();
}
}
async save() {
await this.atomicSave();
}
async atomicSave() {
await fs.mkdir(this.configDir, { recursive: true });
// Acquire file lock to prevent concurrent access
const lockAcquired = await this.acquireLock();
if (!lockAcquired) {
throw new Error('Could not acquire configuration lock. Another process may be modifying the config.');
}
try {
// Set version and update timestamp
this.config.version = CLIConfig.CONFIG_VERSION;
this.config.lastUpdated = new Date().toISOString();
// Create temporary file with unique name
const tempPath = `${this.configPath}.tmp.${randomUUID()}`;
// Write to temporary file first
await fs.writeFile(tempPath, JSON.stringify(this.config, null, 2), 'utf-8');
// Atomic rename - this is the critical atomic operation
await fs.rename(tempPath, this.configPath);
}
finally {
// Always release the lock
await this.releaseLock();
}
}
async backupConfig() {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = path.join(this.configDir, `config.backup.${timestamp}.json`);
try {
// Check if config exists before backing up
await fs.access(this.configPath);
await fs.copyFile(this.configPath, backupPath);
return backupPath;
}
catch (error) {
if (error.code === 'ENOENT') {
// Config doesn't exist, create empty backup
await fs.writeFile(backupPath, JSON.stringify({}, null, 2));
return backupPath;
}
throw error;
}
}
async acquireLock(timeoutMs = 5000) {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
try {
// Try to create lock file exclusively
await fs.writeFile(this.lockFile, process.pid.toString(), { flag: 'wx' });
return true;
}
catch (error) {
if (error.code === 'EEXIST') {
// Lock file exists, check if process is still running
try {
const pidStr = await fs.readFile(this.lockFile, 'utf-8');
const pid = parseInt(pidStr.trim());
if (!isNaN(pid)) {
try {
// Check if process is still running (works on Unix-like systems)
process.kill(pid, 0);
// Process is running, wait and retry
await new Promise(resolve => setTimeout(resolve, 100));
continue;
}
catch {
// Process is not running, remove stale lock
await fs.unlink(this.lockFile).catch(() => { });
continue;
}
}
}
catch {
// Can't read lock file, remove it and retry
await fs.unlink(this.lockFile).catch(() => { });
continue;
}
}
else {
throw error;
}
}
}
return false;
}
async releaseLock() {
try {
await fs.unlink(this.lockFile);
}
catch {
// Lock file might not exist or already removed, ignore
}
}
getApiUrl() {
const baseUrl = process.env.MEMORY_API_URL ||
this.config.apiUrl ||
'https://api.lanonasis.com'; // Primary REST API endpoint
// Ensure we don't double-append /api/v1 - strip it if present since APIClient adds it
return baseUrl.replace(/\/api\/v1\/?$/, '');
}
// Get API URLs with fallbacks - try multiple endpoints
getApiUrlsWithFallbacks() {
const primary = this.getApiUrl();
const fallbacks = [
'https://api.lanonasis.com',
'https://mcp.lanonasis.com'
];
// Remove duplicates and return primary first
return [primary, ...fallbacks.filter(url => url !== primary)];
}
// Enhanced Service Discovery Integration
async discoverServices(verbose = false) {
// Honour manually configured endpoints â skip auto-discovery so we don't clobber them.
if (this.config.manualEndpointOverrides) {
return;
}
const isTestEnvironment = process.env.NODE_ENV === 'test';
const forceDiscovery = process.env.FORCE_SERVICE_DISCOVERY === 'true';
if ((isTestEnvironment && !forceDiscovery) || process.env.SKIP_SERVICE_DISCOVERY === 'true') {
if (!this.config.discoveredServices) {
this.config.discoveredServices = {
auth_base: 'https://auth.lanonasis.com',
memory_base: 'https://api.lanonasis.com', // Primary REST API (Supabase Edge Functions)
mcp_base: 'https://mcp.lanonasis.com/api/v1', // MCP protocol REST path
mcp_ws_base: 'wss://mcp.lanonasis.com/ws',
mcp_sse_base: 'https://mcp.lanonasis.com/api/v1/events',
project_scope: 'lanonasis-maas'
};
}
return;
}
// Try multiple discovery URLs with fallbacks
const discoveryUrls = [
'https://api.lanonasis.com/.well-known/onasis.json',
'https://mcp.lanonasis.com/.well-known/onasis.json'
];
let response = null;
let lastError = null;
// Use axios instead of fetch for consistency
const axios = (await import('axios')).default;
for (const discoveryUrl of discoveryUrls) {
try {
if (verbose) {
console.log(`đ Discovering services from ${discoveryUrl}...`);
}
response = await axios.get(discoveryUrl, {
timeout: 10000,
maxRedirects: 5,
proxy: false, // Bypass proxy to avoid redirect loops
headers: {
'User-Agent': `Lanonasis-CLI/${packageJson.version}`
}
});
if (verbose) {
console.log(`â Successfully discovered services from ${discoveryUrl}`);
}
break; // Success, exit loop
}
catch (err) {
lastError = err;
if (verbose) {
console.log(`â ī¸ Failed to discover from ${discoveryUrl}, trying next...`);
}
continue;
}
}
if (!response) {
throw lastError || new Error('All service discovery URLs failed');
}
try {
const discovered = response.data;
let authBase = discovered.auth?.base || discovered.auth?.login?.replace('/auth/login', '') || '';
if (authBase.includes('localhost') || authBase.includes('127.0.0.1')) {
authBase = 'https://auth.lanonasis.com';
}
// Memory base should be the REST API URL without /api/v1 suffix
// The API client will append the path as needed
const rawMemoryBase = discovered.endpoints?.http || 'https://api.lanonasis.com/api/v1';
const memoryBase = rawMemoryBase.replace(/\/api\/v1\/?$/, '') || 'https://api.lanonasis.com';
this.config.discoveredServices = {
auth_base: authBase || 'https://auth.lanonasis.com',
memory_base: memoryBase,
mcp_base: `${memoryBase}/api/v1`, // Full path for MCP REST calls
mcp_ws_base: discovered.endpoints?.websocket || 'wss://mcp.lanonasis.com/ws',
mcp_sse_base: discovered.endpoints?.sse || `${memoryBase}/api/v1/events`,
project_scope: 'lanonasis-maas'
};
this.config.apiUrl = memoryBase;
this.config.lastServiceDiscovery = new Date().toISOString();
await this.save();
if (verbose) {
console.log('â Service discovery completed successfully');
console.log(` Auth: ${this.config.discoveredServices.auth_base}`);
console.log(` MCP: ${this.config.discoveredServices.mcp_base}`);
console.log(` WebSocket: ${this.config.discoveredServices.mcp_ws_base}`);
}
}
catch (error) {
const normalizedError = this.normalizeServiceError(error);
await this.handleServiceDiscoveryFailure(normalizedError, verbose);
}
}
normalizeServiceError(error) {
if (error && typeof error === 'object') {
if (error instanceof Error) {
return {
...error,
message: error.message
};
}
return error;
}
return {
message: typeof error === 'string' ? error : JSON.stringify(error)
};
}
async handleServiceDiscoveryFailure(error, verbose) {
const errorType = this.categorizeServiceDiscoveryError(error);
if (verbose || process.env.CLI_VERBOSE === 'true') {
console.log('â ī¸ Service discovery failed, using cached/fallback endpoints');
switch (errorType) {
case 'network_error':
console.log(' Reason: Network connection failed');
console.log(' This is normal when offline or behind restrictive firewalls');
break;
case 'timeout':
console.log(' Reason: Request timed out');
console.log(' The discovery service may be temporarily slow');
break;
case 'server_error':
console.log(' Reason: Discovery service returned an error');
console.log(' The service may be temporarily unavailable');
break;
case 'invalid_response':
console.log(' Reason: Invalid response format from discovery service');
console.log(' Using known working endpoints instead');
break;
default:
console.log(` Reason: ${error.message || 'Unknown error'}`);
}
}
if (this.config.discoveredServices && this.config.lastServiceDiscovery) {
const lastDiscovery = new Date(this.config.lastServiceDiscovery);
const hoursSinceDiscovery = (Date.now() - lastDiscovery.getTime()) / (1000 * 60 * 60);
if (hoursSinceDiscovery < 24) {
if (verbose) {
console.log('â Using cached service endpoints (less than 24 hours old)');
}
return;
}
}
const fallback = this.resolveFallbackEndpoints();
this.config.discoveredServices = {
...fallback.endpoints,
project_scope: 'lanonasis-maas'
};
this.config.apiUrl = fallback.endpoints.memory_base;
await this.save();
this.logFallbackUsage(fallback.source, this.config.discoveredServices);
if (verbose) {
console.log('â Using fallback service endpoints');
console.log(` Source: ${fallback.source === 'environment' ? 'environment overrides' : 'built-in defaults'}`);
}
}
categorizeServiceDiscoveryError(error) {
if (error.code) {
switch (error.code) {
case 'ECONNREFUSED':
case 'ENOTFOUND':
case 'ECONNRESET':
case 'ENETUNREACH':
return 'network_error';
case 'ETIMEDOUT':
return 'timeout';
}
}
if ((error.response?.status ?? 0) >= 500) {
return 'server_error';
}
if (error.response?.status === 404) {
return 'invalid_response';
}
const message = error.message?.toLowerCase() || '';
if (message.includes('timeout')) {
return 'timeout';
}
if (message.includes('network') || message.includes('connection')) {
return 'network_error';
}
return 'unknown';
}
resolveFallbackEndpoints() {
const envAuthBase = process.env.LANONASIS_FALLBACK_AUTH_BASE ?? process.env.AUTH_BASE;
const envMemoryBase = process.env.LANONASIS_FALLBACK_MEMORY_BASE ?? process.env.MEMORY_BASE;
const envMcpBase = process.env.LANONASIS_FALLBACK_MCP_BASE ?? process.env.MCP_BASE;
const envMcpWsBase = process.env.LANONASIS_FALLBACK_MCP_WS_BASE ?? process.env.MCP_WS_BASE;
const envMcpSseBase = process.env.LANONASIS_FALLBACK_MCP_SSE_BASE ?? process.env.MCP_SSE_BASE;
const hasEnvOverrides = Boolean(envAuthBase || envMemoryBase || envMcpBase || envMcpWsBase || envMcpSseBase);
const nodeEnv = (process.env.NODE_ENV ?? '').toLowerCase();
const isDevEnvironment = nodeEnv === 'development' || nodeEnv === 'test';
const defaultAuthBase = isDevEnvironment ? 'http://localhost:4000' : 'https://auth.lanonasis.com';
const defaultMemoryBase = isDevEnvironment ? 'http://localhost:4000' : 'https://api.lanonasis.com'; // Primary REST API (Supabase Edge Functions)
const defaultMcpBase = isDevEnvironment ? 'http://localhost:4100/api/v1' : 'https://mcp.lanonasis.com/api/v1'; // MCP protocol REST path
const defaultMcpWsBase = isDevEnvironment ? 'ws://localhost:4100/ws' : 'wss://mcp.lanonasis.com/ws';
const defaultMcpSseBase = isDevEnvironment ? 'http://localhost:4100/api/v1/events' : 'https://mcp.lanonasis.com/api/v1/events';
const endpoints = {
auth_base: envAuthBase ?? defaultAuthBase,
memory_base: envMemoryBase ?? defaultMemoryBase,
mcp_base: envMcpBase ?? defaultMcpBase,
mcp_ws_base: envMcpWsBase ?? defaultMcpWsBase,
mcp_sse_base: envMcpSseBase ?? defaultMcpSseBase
};
return {
endpoints,
source: hasEnvOverrides ? 'environment' : 'default'
};
}
logFallbackUsage(source, endpoints) {
const summary = {
auth: endpoints.auth_base,
mcp: endpoints.mcp_base,
websocket: endpoints.mcp_ws_base,
sse: endpoints.mcp_sse_base,
source
};
const message = `Service discovery fallback activated using ${source === 'environment' ? 'environment overrides' : 'built-in defaults'}`;
console.warn(`â ī¸ ${message}`);
console.info('đ service_discovery_fallback', summary);
if (typeof process.emitWarning === 'function') {
process.emitWarning(message, 'ServiceDiscoveryFallback');
}
}
async pingAuthHealth(axiosInstance, authBase, headers, options = {}) {
const normalizedBase = authBase.replace(/\/$/, '');
const endpoints = [
`${normalizedBase}/health`,
`${normalizedBase}/api/v1/health`
];
let lastError;
for (const endpoint of endpoints) {
try {
const requestConfig = {
headers,
timeout: options.timeout ?? 10000
};
if (options.proxy === false) {
requestConfig.proxy = false;
}
await axiosInstance.get(endpoint, requestConfig);
return;
}
catch (error) {
lastError = error;
}
}
if (lastError instanceof Error) {
throw lastError;
}
throw new Error('Auth health endpoints unreachable');
}
getAuthVerificationEndpoints(pathname) {
const authBase = (this.config.discoveredServices?.auth_base || 'https://auth.lanonasis.com').replace(/\/$/, '');
return Array.from(new Set([
`${authBase}${pathname}`,
`https://auth.lanonasis.com${pathname}`,
`http://localhost:4000${pathname}`
]));
}
extractAuthErrorMessage(payload) {
if (!payload || typeof payload !== 'object') {
return undefined;
}
const data = payload;
const fields = ['message', 'error', 'reason', 'code'];
for (const field of fields) {
const value = data[field];
if (typeof value === 'string' && value.trim().length > 0) {
return value;
}
}
return undefined;
}
async verifyTokenWithAuthGateway(token) {
const headers = {
'Authorization': `Bearer ${token}`,
'X-Project-Scope': 'lanonasis-maas'
};
let fallbackReason = 'Unable to verify token with auth gateway';
// Primary check (required by auth contract): /v1/auth/verify
for (const endpoint of this.getAuthVerificationEndpoints('/v1/auth/verify')) {
try {
const response = await axios.post(endpoint, {}, {
headers,
timeout: 5000,
proxy: false
});
const payload = response.data;
if (payload.valid === true || Boolean(payload.payload)) {
return { valid: true, method: 'token', endpoint };
}
if (payload.valid === false) {
return {
valid: false,
method: 'token',
endpoint,
reason: this.extractAuthErrorMessage(payload) || 'Token is invalid'
};
}
}
catch (error) {
const normalizedError = this.normalizeServiceError(error);
const responsePayload = normalizedError.response?.data;
const responseCode = typeof responsePayload?.code === 'string' ? responsePayload.code : undefined;
const reason = this.extractAuthErrorMessage(responsePayload) || normalizedError.message || fallbackReason;
fallbackReason = reason;
// If auth gateway explicitly rejected token, stop early.
if ((normalizedError.response?.status === 401 || normalizedError.response?.status === 403) &&
responseCode &&
responseCode !== 'AUTH_TOKEN_MISSING') {
return { valid: false, method: 'token', endpoint, reason };
}
}
}
// Fallback for deployments where proxy layers strip Authorization headers.
for (const endpoint of this.getAuthVerificationEndpoints('/v1/auth/verify-token')) {
try {
const response = await axios.post(endpoint, { token }, {
headers: {
'Content-Type': 'application/json',
'X-Project-Scope': 'lanonasis-maas'
},
timeout: 5000,
proxy: false
});
const payload = response.data;
if (payload.valid === true) {
return { valid: true, method: 'token', endpoint };
}
if (payload.valid === false) {
return {
valid: false,
method: 'token',
endpoint,
reason: this.extractAuthErrorMessage(payload) || 'Token is invalid'
};
}
}
catch (error) {
const normalizedError = this.normalizeServiceError(error);
const responsePayload = normalizedError.response?.data;
fallbackReason = this.extractAuthErrorMessage(responsePayload) || normalizedError.message || fallbackReason;
}
}
return {
valid: false,
method: 'token',
reason: fallbackReason
};
}
async verifyVendorKeyWithAuthGateway(vendorKey) {
// Detect whether the stored "vendor key" is actually an OAuth/JWT access token
// (3-part base64url string separated by dots). OAuth tokens must be verified via the
// Bearer token path (/v1/auth/verify-token), not as API keys (/v1/auth/verify-api-key),
// because they are not stored in the api_keys table.
const isJwtFormat = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(vendorKey.trim());
if (isJwtFormat) {
// Delegate to token verification â the auth gateway's UAI router accepts Bearer tokens
// on /v1/auth/verify (requireAuth) and /v1/auth/verify-token (public, body-based).
return this.verifyTokenWithAuthGateway(vendorKey);
}
const headers = {
'X-API-Key': vendorKey,
'X-Auth-Method': 'vendor_key',
'X-Project-Scope': 'lanonasis-maas'
};
let fallbackReason = 'Unable to verify API key with auth gateway';
// Primary check (required by auth contract): /v1/auth/verify
for (const endpoint of this.getAuthVerificationEndpoints('/v1/auth/verify')) {
try {
const response = await axios.post(endpoint, {}, {
headers,
timeout: 5000,
proxy: false
});
const payload = response.data;
if (payload.valid === true || Boolean(payload.payload)) {
return { valid: true, method: 'vendor_key', endpoint };
}
if (payload.valid === false) {
return {
valid: false,
method: 'vendor_key',
endpoint,
reason: this.extractAuthErrorMessage(payload) || 'API key is invalid'
};
}
}
catch (error) {
const normalizedError = this.normalizeServiceError(error);
const responsePayload = normalizedError.response?.data;
const responseCode = typeof responsePayload?.code === 'string' ? responsePayload.code : undefined;
const reason = this.extractAuthErrorMessage(responsePayload) || normalizedError.message || fallbackReason;
fallbackReason = reason;
// If auth gateway explicitly rejected API key, stop early.
if ((normalizedError.response?.status === 401 || normalizedError.response?.status === 403) &&
responseCode &&
responseCode !== 'AUTH_TOKEN_MISSING') {
return { valid: false, method: 'vendor_key', endpoint, reason };
}
}
}
// Fallback for deployments where reverse proxies don't forward custom auth headers on /verify.
for (const endpoint of this.getAuthVerificationEndpoints('/v1/auth/verify-api-key')) {
try {
const response = await axios.post(endpoint, {}, {
headers,
timeout: 5000,
proxy: false
});
const payload = response.data;
if (payload.valid === true) {
return { valid: true, method: 'vendor_key', endpoint };
}
if (payload.valid === false) {
return {
valid: false,
method: 'vendor_key',
endpoint,
reason: this.extractAuthErrorMessage(payload) || 'API key is invalid'
};
}
}
catch (error) {
const normalizedError = this.normalizeServiceError(error);
const responsePayload = normalizedError.response?.data;
fallbackReason = this.extractAuthErrorMessage(responsePayload) || normalizedError.message || fallbackReason;
}
}
return {
valid: false,
method: 'vendor_key',
reason: fallbackReason
};
}
async verifyCurrentCredentialsWithServer() {
await this.refreshTokenIfNeeded();
await this.discoverServices();
const token = this.getToken();
const vendorKey = await this.getVendorKeyAsync();
if (this.config.authMethod === 'vendor_key' && this.isLegacyHashedCredential(vendorKey)) {
return {
valid: false,
method: 'vendor_key',
reason: this.getLegacyHashedVendorKeyReason()
};
}
if (this.config.authMethod === 'vendor_key' && vendorKey) {
return this.verifyVendorKeyWithAuthGateway(vendorKey);
}
if (token) {
return this.verifyTokenWithAuthGateway(token);
}
if (vendorKey) {
return this.verifyVendorKeyWithAuthGateway(vendorKey);
}
return {
valid: false,
method: 'none',
reason: 'No credentials configured'
};
}
// Manual endpoint override functionality
async setManualEndpoints(endpoints) {
if (!this.config.discoveredServices) {
// Initialize with defaults first
await this.discoverServices();
}
const currentServices = this.config.discoveredServices ?? {
auth_base: 'https://auth.lanonasis.com',
memory_base: 'https://api.lanonasis.com', // Primary REST API (Supabase Edge Functions)
mcp_base: 'https://mcp.lanonasis.com/api/v1', // MCP protocol REST path
mcp_ws_base: 'wss://mcp.lanonasis.com/ws',
mcp_sse_base: 'https://mcp.lanonasis.com/api/v1/events',
project_scope: 'lanonasis-maas'
};
// Merge manual overrides with existing endpoints
this.config.discoveredServices = {
...currentServices,
...endpoints
};
// Mark as manually configured
this.config.manualEndpointOverrides = true;
this.config.lastManualEndpointUpdate = new Date().toISOString();
await this.save();
}
hasManualEndpointOverrides() {
return !!this.config.manualEndpointOverrides;
}
/**
* Clears the in-memory auth cache so that the next `isAuthenticated()` call
* performs a fresh server verification rather than returning a stale cached result.
*
* NOTE: `lastValidated` is intentionally NOT deleted here. Each auth path
* (vendor_key, token) already correctly rejects 401 responses without relying
* on `lastValidated`. Deleting it would destroy the offline grace period
* (7-day for vendor keys, 24-hour for JWT tokens), causing auth failures
* on transient network errors even when credentials are valid.
*/
async invalidateAuthCache() {
this.authCheckCache = null;
}
async clearManualEndpointOverrides() {
delete this.config.manualEndpointOverrides;
delete this.config.lastManualEndpointUpdate;
// Rediscover services
await this.discoverServices();
}
getDiscoveredApiUrl() {
return process.env.AUTH_BASE ||
this.config.discoveredServices?.auth_base ||
'https://auth.lanonasis.com';
}
// Enhanced authentication support
async setVendorKey(vendorKey, options = {}) {
const trimmedKey = typeof vendorKey === 'string' ? vendorKey.trim() : '';
// Minimal format validation (non-empty); rely on server-side checks for everything else
const formatValidation = this.validateVendorKeyFormat(trimmedKey);
if (formatValidation !== true) {
throw new Error(typeof formatValidation === 'string' ? formatValidation : 'Vendor key is invalid');
}
// Skip server-side validation when the caller already holds a valid auth credential
// (e.g. an OAuth access token being stored for MCP/API access â auth-gateway won't
// recognise it as a vendor key even though the memory API accepts it).
const isOAuthContext = ['oauth', 'oauth2'].includes(this.config.authMethod || '');
if (!options.skipServerValidation && !isOAuthContext) {
await this.validateVendorKeyWithServer(trimmedKey);
}
// Initialize and store using ApiKeyStorage from @lanonasis/oauth-client
// This handles encryption automatically (AES-256-GCM with machine-derived key)
const apiKeyStorage = this.getApiKeyStorage();
await apiKeyStorage.initialize();
await apiKeyStorage.store({
apiKey: trimmedKey,
organizationId: this.config.user?.organization_id,
userId: this.config.user?.email,
environment: process.env.NODE_ENV || 'production',
createdAt: new Date().toISOString()
});
// Cache the vendor key for this process so synchronous callers can reuse it
this.vendorKeyCache = trimmedKey;
if (process.env.CLI_VERBOSE === 'true') {
console.log('đ Vendor key stored securely via @lanonasis/oauth-client');
}
// Store a reference marker in config (not the actual key)
this.config.vendorKey = 'stored_in_api_key_storage';
// Only set authMethod to 'vendor_key' if not already set to OAuth
// This prevents overwriting OAuth auth method when storing the token for MCP access
if (!this.config.authMethod || !['oauth', 'oauth2', 'jwt'].includes(this.config.authMethod)) {
this.config.authMethod = 'vendor_key';
}
this.config.lastValidated = new Date().toISOString();
await this.resetFailureCount(); // Reset failure count on successful auth
await this.save();
}
validateVendorKeyFormat(vendorKey) {
const trimmed = typeof vendorKey === 'string' ? vendorKey.trim() : '';
if (!trimmed) {
return 'Vendor key is required';
}
return true;
}
async validateVendorKeyWithServer(vendorKey) {
if (process.env.SKIP_SERVER_VALIDATION === 'true') {
return;
}
try {
await this.discoverServices();
const verification = await this.verifyVendorKeyWithAuthGateway(vendorKey);
if (verification.valid) {
return;
}
throw new Error(verification.reason || 'Authentication failed. The key may be invalid, expired, or revoked.');
}
catch (error) {
const normalizedError = this.normalizeServiceError(error);
// Provide specific error messages based on response
if (normalizedError.response?.status === 401) {
const errorData = normalizedError.response.data;
if (errorData?.error?.includes('expired') || errorData?.message?.includes('expired')) {
throw new Error('Vendor key validation failed: Key has expired. Please generate a new key from your dashboard.');
}
else if (errorData?.error?.includes('revoked') || errorData?.message?.includes('revoked')) {
throw new Error('Vendor key validation failed: Key has been revoked. Please generate a new key from your dashboard.');
}
else if (errorData?.error?.includes('invalid') || errorData?.message?.includes('invalid')) {
throw new Error('Vendor key validation failed: Key is invalid. Please check the key format and ensure it was copied correctly.');
}
else {
throw new Error('Vendor key validation failed: Authentication failed. The key may be invalid, expired, or revoked.');
}
}
else if (normalizedError.response?.status === 403) {
throw new Error('Vendor key access denied. The key may not have sufficient permissions for this operation.');
}
else if (normalizedError.response?.status === 429) {
throw new Error('Too many validation attempts. Please wait a moment before trying again.');
}
else if ((normalizedError.response?.status ?? 0) >= 500) {
throw new Error('Server error during validation. Please try again in a few moments.');
}
else if (normalizedError.code === 'ECONNREFUSED') {
throw new Error('Cannot connect to authentication server. Please check your internet connection and try again.');
}
else if (normalizedError.code === 'ENOTFOUND') {
throw new Error('Authentication server not found. Please check your internet connection.');
}
else if (normalizedError.code === 'ETIMEDOUT') {
throw new Error('Validation request timed out. Please check your internet connection and try again.');
}
else if (normalizedError.code === 'ECONNRESET') {
throw new Error('Connection was reset during validation. Please try again.');
}
else {
throw new Error(`Vendor key validation failed: ${normalizedError.message || 'Unknown error'}`);
}
}
}
getVendorKey() {
if (this.vendorKeyCache) {
return this.vendorKeyCache;
}
try {
// Retrieve from secure storage using ApiKeyStorage (synchronous wrapper)
const stored = this.getVendorKeySync();
if (stored) {
this.vendorKeyCache = stored;
}
return this.vendorKeyCache;
}
catch (error) {
if (process.env.CLI_VERBOSE === 'true') {
console.error('â ī¸ Failed to load vendor key from secure storage:', error);
}
return undefined;
}
}
/**
* Synchronous wrapper for async retrieve operation
* Note: ApiKeyStorage.retrieve() is async but we need sync for existing code
*/
getVendorKeySync() {
// For now, check legacy storage. We'll update callers to use async later
if (this.config.vendorKey && this.config.vendorKey !== 'stored_in_api_key_storage') {
if (process.env.CLI_VERBOSE === 'true') {
console.log('âšī¸ Using legacy vendor key storage');
}
return this.config.vendorKey;
}
return undefined;
}
/**
* Async method to get vendor key from secure storage
*/
async getVendorKeyAsync() {
try {
const apiKeyStorage = this.getApiKeyStorage();
await apiKeyStorage.initialize();
const stored = await apiKeyStorage.retrieve();
if (stored) {
this.vendorKeyCache = stored.apiKey;
return this.vendorKeyCache;
}
}
catch (error) {
if (process.env.CLI_VERBOSE === 'true') {
console.error('â ī¸ Failed to retrieve vendor key:', error);
}
}
// Fallback: check for legacy plaintext storage in config
if (this.config.vendorKey && this.config.vendorKey !== 'stored_in_api_key_storage') {
if (process.env.CLI_VERBOSE === 'true') {
console.log('âšī¸ Found legacy plaintext vendor key, will migrate on next auth');
}
this.vendorKeyCache = this.config.vendorKey;
return this.vendorKeyCache;
}
return undefined;
}
hasVendorKey() {
// Check for marker or legacy storage
return !!(this.vendorKeyCache || this.config.vendorKey);
}
async setApiUrl(url) {
this.config.apiUrl = url;
await this.save();
}
async setToken(token) {
this.config.token = token;
this.config.authMethod = 'jwt';
this.config.lastValidated = new Date().toISOString();
await this.resetFailureCount(); // Reset failure count on successful auth
// Decode token to get user info and expiry
try {
const decoded = jwtDecode(token);
// Store token expiry
if (typeof decoded.exp === 'number') {
this.config.tokenExpiry = decoded.exp;
}
// Store user info
this.config.user = this.buildUserProfile(decoded, this.config.user);
}
catch {
// Invalid token, don't store user info or expiry
this.config.tokenExpiry = undefined;
// Mark as non-JWT (e.g., OAuth/CLI token)
this.config.authMethod = this.config.authMethod || 'oauth';
}
await this.save();
}
getToken() {
return this.config.token;
}
getAuthMethod() {
return this.config.authMethod;
}
async getCurrentUser() {
return this.config.user;
}
async updateCurrentUserProfile(profile) {
const nextUser = this.buildUserProfile(profile, this.config.user);
if (!nextUser) {
return;
}
const currentUser = this.config.user;
const changed = !currentUser
|| currentUser.email !== nextUser.email
|| currentUser.organization_id !== nextUser.organization_id
|| currentUser.role !== nextUser.role
|| currentUser.plan !== nextUser.plan;
if (!changed) {
return;
}
this.config.user = nextUser;
await this.save();
}
async isAuthenticated() {
// Attempt refresh for OAuth sessions before checks (prevents intermittent auth dropouts).
// This is safe to call even when not using OAuth; it will no-op.
await this.refreshTokenIfNeeded();
// Check if using vendor key authentication
if (this.config.authMethod === 'vendor_key') {
// Use async method to read from encrypted ApiKeyStorage
const vendorKey = await this.getVendorKeyAsync();
if (!vendorKey)
return false;
if (this.isLegacyHashedCredential(vendorKey)) {
if (process.env.CLI_VERBOSE === 'true') {
console.warn(`â ī¸ ${this.getLegacyHashedVendorKeyReason()}`);
}
this.authCheckCache = { isValid: false, timestamp: Date.now() };
return false;
}
// Check in-memory cache first (5-minute TTL)
if (this.authCheckCache && (Date.now() - this.authCheckCache.timestamp) < this.AUTH_CACHE_TTL) {
return this.authCheckCache.isValid;
}
// Track lastValidated for the offline grace period used in the catch block.
// The 24-hour skip-server-validation gate has been removed: it allowed expired/revoked
// keys to appear valid as long as they had been validated within the past day.
const lastValidated = this.config.lastValidated;
// Verify with server on every cache-miss
try {
const verification = await this.verifyVendorKeyWithAuthGateway(vendorKey);
if (!verification.valid) {
// Auth gateway explicitly rejected the key â no grace period applies.
this.authCheckCache = { isValid: false, timestamp: Date.now() };
return false;
}
// Update last validated timestamp on success
this.config.lastValidated = new Date().toISOString();
await this.save().catch(() => { }); // Don't fail auth check if save fails
this.authCheckCache = { isValid: true, timestamp: Date.now() };
return true;
}
catch (err) {
// verifyVendorKeyWithAuthGateway throws only on network/timeout errors
// (explicit 401/403 returns {valid:false} without throwing).
// Apply the 7-day offline grace ONLY for genuine network failures.
const normalizedErr = this.normalizeServiceError(err);
const httpStatus = normalizedErr.response?.status ?? 0;
if (httpStatus === 401 || httpStatus === 403) {
// Explicit auth rejection propagated as an exception â definitely invalid.
this.authCheckCache = { isValid: false, timestamp: Date.now() };
return false;
}
// Network / server error â apply offline grace period
const gracePeriod = 7 * 24 * 60 * 60 * 1000;
const withinGracePeriod = lastValidated &&
(Date.now() - new Date(lastValidated).getTime()) < gracePeriod;
if (withinGracePeriod) {
if (process.env.CLI_VERBOSE === 'true') {
console.warn('â ī¸ Unable to validate vendor key with server, using cached validation');
}
this.authCheckCache = { isValid: true, timestamp: Date.now() };
return true;
}
if (process.env.CLI_VERBOSE === 'true') {
console.warn('â ī¸ Vendor key validation failed and grace period expired');
}
this.authCheckCache = { isValid: false, timestamp: Date.now() };
return false;
}
}
// Handle token-based authentication
const token = this.getToken();
if (!token)
return false;
// OAuth tokens are often opaque (not JWT). Use local expiry metadata as a quick
// pre-check, but do not treat it as authoritative for a "true" result. We still
// run server verification on cache misses to avoid status/API drift.
let oauthTokenLocallyValid;
if (this.config.authMethod === 'oauth' || this.config.authMethod === 'oauth2') {
const tokenExpiresAt = this.get('token_expires_at');
if (typeof tokenExpiresAt === 'number') {
oauthTokenLocallyValid = Date.now() < tokenExpiresAt;
if (!oauthTokenLocallyValid) {
this.authCheckCache = { isValid: false, timestamp: Date.now() };
return false;
}
}
// Fall through to server validation path.
}
// Check cache first
if (this.authCheckCache && (Date.now() - this.authCheckCache.timestamp) < this.AUTH_CACHE_TTL) {
return this.authCheckCache.isValid;
}
// Local expiry check first (fast)
let locallyValid = false;
if (typeof oauthTokenLocallyValid === 'boolean') {
locallyValid = oauthTokenLocallyValid;
}
else if (token.startsWith('cli_')) {
// Handle simple CLI tokens (format: cli_xxx_timestamp)
const parts = token.split('_');
if (parts.length >= 3) {
const lastPart = parts[parts.length - 1];
const timestamp = lastPart ? parseInt(lastPart) : NaN;
if (!isNaN(timestamp)) {
// CLI tokens are valid for 30 days
const thirtyDaysInMs = 30 * 24 * 60 * 60 * 1000;
locallyValid = (Date.now() - timestamp) < thirtyDaysInMs;
}
}
else {
locallyValid = true; // Fallback for old format
}
}
else {
// Handle JWT tokens
try {
const decoded = jwtDecode(token);
const now = Date.now() / 1000;
locallyValid = typeof decoded.exp === 'number' && decoded.exp > now;
}
catch {
locallyValid = false;
}
}
// If not locally valid, attempt server verification before failing
if (!locallyValid) {
try {
const endpoints = [
'http://localhost:4000/v1/auth/verify-token',
'https://auth.lanonasis.com/v1/auth/verify-token'
];
for (const endpoint of endpoints) {
try {
const resp = await axios.post(endpoint, { token }, { timeout: 3000 });
if (resp.data?.valid === true) {
this.authCheckCache = { isValid: true, timestamp: Date.now() };
return true;
}
}
catch {
// try next endpoint
continue;
}
}
}
catch {
// ignore, will fall back to failure below
}
this.authCheckCache = { isValid: false, timestamp: Date.now() };
return false;
}
// Token is locally valid - verify with server on cache miss for consistency
try {
// Try auth-gateway first (port 4000), then fall back to Netlify function
const endpoints = [
'http://localhost:4000/v1/auth/verify-token',
'https://auth.lanonasis.com/v1/auth/verify-token'
];
let response = null;
let networkError = false;
let authError = false;
for (const endpoint of endpoints) {
try {
response = await axios.post(endpoint, { token }, { timeout: 3000 });
if (response.data.valid === true) {
break;
}
// Explicit auth rejection should always invalidate local auth state.
if (response.status === 401 || response.status === 403 || response.data.valid === false) {
authError = true;
}
else {
// Non-auth failures (like 404/5xx) should behave like transient verification failures.
networkError = true;
}
}
catch (error) {
// Check if this is a network/transient error vs explicit auth rejection.
if (error.response) {
const status = error.response.status;
if (status === 401 || status === 403) {
authError = true;
}
else {
networkError = true;
}
}
else {
// Network error (ECONNREFUSED, ETIMEDOUT, etc.)
networkError = true;
}
// Try next endpoint
continue;
}
}
if (!response || response.data.valid !== true) {
// If server explicitly rejected (auth error), don't trust local validation
if (authError) {
if (process.env.CLI_VERBOSE === 'true') {
console.warn('â ī¸ Server validation failed with authentication error - token is invalid');
}
this.authCheckCache = { isValid: false, timestamp: Date.now() };
return false;
}
// If purely network error AND locally valid AND recently validated (within 7 days)
// allow offline usage with grace period
if (networkError && locallyValid) {
const gracePeriod = 7 * 24 * 60 * 60 * 1000; // 7 days
const lastValidated = this.config.lastValidated;
const withinGracePeriod = lastValidated &&
(Date.now() - new Date(lastValidated).getTime()) < gracePeriod;
if (withinGracePeriod) {
if (process.env.CLI_VERBOSE === 'true') {
console.warn('â ī¸ Unable to reach server, using cached validation (offline mode)');
}
this.authCheckCache = { isValid: true, timestamp: Date.now() };
return true;
}
else {
if (process.env.CLI_VERBOSE === 'true') {
console.warn('â ī¸ Token validation grace period expired, server validation required');
}
this.authCheckCache = { isValid: false, timestamp: Date.now() };
return false;
}
}
// Default to invalid if we can't validate
this.authCheckCache = { isValid: false, timestamp: Date.now() };
return false;
}
// Update lastValidated on successful server validation
this.config.lastValidated = new Date().toISOString();
await this.save().catch(() => { }); // Don't fail auth check if save fails
this.authCheckCache = { isValid: true, timestamp: Date.now() };
return true;
}
catch {
// If all server checks fail, fall back to local validation
// This allows offline usage but is less secure
if (process.env.CLI_VERBOSE === 'true') {
console.warn('â ī¸ Unable to verify token with server, using local validation');
}
this.authCheckCache = { isValid: locallyValid, timestamp: Date.now() };
return locallyValid;
}
}
async logout() {
this.config.token = undefined;
this.config.user = undefined;
this.vendorKeyCache = undefined;
this.config.vendorKey = undefined;
this.config.authMethod = undefined;
this.config.refresh_token = undefined;
this.config.token_expires_at = undefined;
try {
const apiKeyStorage = this.getApiKeyStorage();
await apiKeyStorage.initialize();
// ApiKeyStorage may implement clear() to remove encrypted secrets
const storage = apiKeyStorage;
if (typeof storage.clear === 'function') {
await storage.clear();
}
}
catch {
// Ignore storage cleanup errors during logout
}
await this.save();
}
async clear() {
this.config = {};
this.vendorKeyCache = undefined;
await this.save();
}
async exists() {
try {
await fs.access(this.configPath);
return true;
}
catch {
return false;
}
}
// Enhanced credential validation methods
async validateStoredCredentials() {
try {
const vendorKey = await this.getVendorKeyAsync();
const token = this.getToken();
if (!vendorKey && !token) {
return false;
}
const verification = this.config.authMethod === 'vendor_key' && vendorKey
? await this.verifyVendorKeyWithAuthGateway(vendorKey)
: token
? await this.verifyTokenWithAuthGateway(token)
: await this.verifyVendorKeyWithAuthGateway(vendorKey);
if (!verification.valid) {
throw new Error(verification.reason || 'Stored credentials are invalid');
}
// Update last validated timestamp
this.config.lastValidated = new Date().toISOString();
await this.resetFailureCount();
await this.save();
return true;
}
catch {
// Increment failure count
await this.incrementFailureCount();
return false;
}
}
async refreshTokenIfNeeded() {
const token = this.getToken();
if (!token) {
return;
}
try {
// Vendor-key sessions should never attempt token refresh.
// Some environments retain stale token/refresh_token fields from older logins,
// which can otherwise trip dead refresh routes during normal memory commands.
if (String(this.config.authMethod || '').toLowerCase() === 'vendor_key') {
return;
}
const refreshToken = this.get('refresh_token');
// OAuth token refresh (opaque tokens + refresh_token + token_expires_at)
if (this.config.authMethod === 'oauth' || this.config.authMethod === 'oauth2') {
if (!refreshToken) {
return;
}
const tokenExpiresAtRaw = this.get('token_expires_at');
const tokenExpiresAt = (() => {
const n = typeof tokenExpiresAtRaw === 'number'
? tokenExpiresAtRaw
: typeof tokenExpiresAtRaw === 'string'
? Number(tokenExpiresAtRaw)
: undefined;
if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) {
return undefined;
}
// Support both seconds and milliseconds since epoch.
// Seconds are ~1.7e9; ms are ~1.7e12.
return n < 1e11 ? n * 1000 : n;
})();
const nowMs = Date.now();
const refreshWindowMs = 5 * 60 * 1000; // 5 minutes
// If we don't know expiry, don't force a refresh.
if (typeof tokenExpiresAt !== 'number' || nowMs < (tokenExpiresAt - refreshWindowMs)) {
return;
}
await this.discoverServices();
await this.refreshViaOAuthTokenEndpoint(refreshToken, this.getDiscoveredApiUrl(), 'oauth');
return;
}
// Check if token is JWT and if it's close to expiry
if (token.startsWith('cli_')) {
// CLI tokens don't need refresh, they're long-lived
return;
}
// Only attempt JWT refresh for tokens that look like JWTs.
// OAuth access tokens in this system can be opaque strings; treating them as JWTs
// creates noisy failures and can cause unwanted state writes.
if (token.split('.').length !== 3) {
return;
}
const decoded = jwtDecode(token);
const now = Date.now() / 1000;
const exp = typeof decoded.exp === 'number' ? decoded.exp : 0;
// Refresh if token expires within 5 minutes
if (exp > 0 && (exp - now) < 300) {
if (!refreshToken) {
return;
}
await this.discoverServices();
await this.refreshViaOAuthTokenEndpoint(refreshToken, this.getDiscoveredApiUrl(), 'jwt');
}
}
catch (err) {
// If refresh fails, mark credentials as potentially invalid
await this.incrementFailureCount();
if (process.env.CLI_VERBOSE === 'true' || process.env.NODE_ENV !== 'production') {
console.debug('Token refresh failed:', err.message);
}
}
}
async refreshViaOAuthTokenEndpoint(refreshToken, authBase, authMethod) {
const resp = await axios.post(`${authBase}/oauth/token`, {
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: 'lanonasis-cli'
}, {
headers: { 'Content-Type': 'application/json' },
timeout: 10000,
proxy: false
});
const raw = resp?.data;
const payload = raw && typeof raw === 'object' && raw.data && typeof raw.data === 'object'
? raw.data
: raw;
const accessToken = payload?.access_token ?? payload?.token;
const refreshedRefreshToken = payload?.refresh_token;
const expiresIn = payload?.expires_in;
if (typeof accessToken !== 'string' || accessToken.length === 0) {
throw new Error('Token refresh response missing access_token');
}
await this.setToken(accessToken);
this.config.authMethod = authMethod;
if (typeof refreshedRefreshToken === 'string' && refreshedRefreshToken.length > 0) {
this.config.refresh_token = refreshedRefreshToken;
}
if (typeof expiresIn === 'number' && Number.isFinite(expiresIn)) {
this.config.token_expires_at = Date.now() + (expiresIn * 1000);
}
await this.save().catch(() => { });
}
async clearInvalidCredentials() {
this.config.token = undefined;
this.config.vendorKey = undefined;
this.config.user = undefined;
this.config.authMethod = undefined;
this.config.tokenExpiry = undefined;
this.config.refresh_token = undefined;
this.config.token_expires_at = undefined;
this.config.lastValidated = undefined;
this.config.authFailureCount = 0;
this.config.lastAuthFailure = undefined;
this.vendorKeyCache = undefined;
try {
const apiKeyStorage = this.getApiKeyStorage();
await apiKeyStorage.initialize();
const storage = apiKeyStorage;
if (typeof storage.clear === 'function') {
await storage.clear();
}
}
catch {
// Ignore secure storage cleanup failures while invalidating credentials
}
await this.save();
}
async incrementFailureCount() {
this.config.authFailureCount = (this.config.authFailureCount || 0) + 1;
this.config.lastAuthFailure = new Date().toISOString();
await this.save();
}
async resetFailureCount() {
this.config.authFailureCount = 0;
this.config.lastAuthFailure = undefined;
await this.save();
}
getFailureCount() {
return this.config.authFailureCount || 0;
}
getLastAuthFailure() {
return this.config.lastAuthFailure;
}
shouldDelayAuth() {
const failureCount = this.getFailureCount();
return failureCount >= 3;
}
getAuthDelayMs() {
const failureCount = this.getFailureCount();
if (failureCount < 3)
return 0;
// Progressive delays: 3 failures = 2s, 4 = 4s, 5 = 8s, 6+ = 16s max
const baseDelay = 2000; // 2 seconds
const maxDelay = 16000; // 16 seconds max
const delay = Math.min(baseDelay * Math.pow(2, failureCount - 3), maxDelay);
return delay;
}
async getDeviceId() {
if (!this.config.deviceId) {
// Generate a new device ID
this.config.deviceId = randomUUID();
await this.save();
}
return this.config.deviceId;
}
// Generic get/set methods for MCP and other dynamic config
get(key) {
return this.config[key];
}
set(key, value) {
this.config[key] = value;
}
async setAndSave(key, value) {
this.set(key, value);
await this.save();
}
// MCP-specific helpers
getMCPServerPath() {
// Only return an explicitly configured path. No implicit bundled defaults.
// Returning an empty string if unset helps callers decide how to proceed safely.
return this.config.mcpServerPath || '';
}
getMCPServerUrl() {
return this.config.discoveredServices?.mcp_ws_base ||
this.config.mcpServerUrl ||
'wss://mcp.lanonasis.com/ws';
}
getMCPRestUrl() {
const configured = this.config.mcpServerUrl;
if (typeof configured === 'string' && configured.trim().length > 0) {
return configured.trim();
}
const discoveredMcpBase = this.config.discoveredServices?.mcp_base;
if (typeof discoveredMcpBase === 'string' && discoveredMcpBase.trim().length > 0) {
const normalizedMcpBase = discoveredMcpBase.trim().replace(/\/$/, '');
const normalizedMemoryBase = (this.config.discoveredServices?.memory_base || '')
.toString()
.trim()
.replace(/\/$/, '');
// Guard against service-discovery payloads that map MCP REST to the memory API host.
const pointsToMemoryBase = normalizedMemoryBase.length > 0 &&
normalizedMcpBase.replace(/\/api\/v1$/, '') === normalizedMemoryBase;
if (!pointsToMemoryBase) {
return normalizedMcpBase;
}
}
return 'https://mcp.lanonasis.com/api/v1';
}
getMCPSSEUrl() {
return this.config.discoveredServices?.mcp_sse_base ||
'https://mcp.lanonasis.com/api/v1/events';
}
shouldUseRemoteMCP() {
const preference = this.config.mcpPreference || 'auto';
switch (preference) {
case 'websocket':
case 'remote':
return true;
case 'local':
return false;
case 'auto':
default:
// Default to remote/websocket (production mode)
// Local mode should only be used when explicitly configured
return true;
}
}
}