@aifabrix/builder 2.6.2 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Schema Loading Utilities
3
+ *
4
+ * Loads and compiles JSON schemas for validation.
5
+ * Provides schema type detection and cached validators.
6
+ *
7
+ * @fileoverview Schema loading utilities for AI Fabrix Builder
8
+ * @author AI Fabrix Team
9
+ * @version 2.0.0
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const Ajv = require('ajv');
15
+
16
+ // Cache for compiled validators
17
+ // These are reset when module is reloaded (for testing)
18
+ let externalSystemValidator = null;
19
+ let externalDataSourceValidator = null;
20
+
21
+ /**
22
+ * Reset validators cache (for testing)
23
+ * @function resetValidators
24
+ */
25
+ function resetValidators() {
26
+ externalSystemValidator = null;
27
+ externalDataSourceValidator = null;
28
+ }
29
+
30
+ /**
31
+ * Loads and compiles external-system schema
32
+ * Caches the compiled validator for performance
33
+ *
34
+ * @function loadExternalSystemSchema
35
+ * @returns {Function} Compiled AJV validator function
36
+ * @throws {Error} If schema file cannot be loaded or compiled
37
+ *
38
+ * @example
39
+ * const validate = loadExternalSystemSchema();
40
+ * const valid = validate(data);
41
+ */
42
+ function loadExternalSystemSchema() {
43
+ if (externalSystemValidator) {
44
+ return externalSystemValidator;
45
+ }
46
+
47
+ const schemaPath = path.join(__dirname, '..', 'schema', 'external-system.schema.json');
48
+
49
+ if (!fs.existsSync(schemaPath)) {
50
+ throw new Error(`External system schema not found: ${schemaPath}`);
51
+ }
52
+
53
+ const schemaContent = fs.readFileSync(schemaPath, 'utf8');
54
+ let schema;
55
+
56
+ try {
57
+ schema = JSON.parse(schemaContent);
58
+ } catch (error) {
59
+ throw new Error(`Invalid JSON in external-system.schema.json: ${error.message}`);
60
+ }
61
+
62
+ const ajv = new Ajv({ allErrors: true, strict: false });
63
+ externalSystemValidator = ajv.compile(schema);
64
+
65
+ return externalSystemValidator;
66
+ }
67
+
68
+ /**
69
+ * Loads and compiles external-datasource schema
70
+ * Caches the compiled validator for performance
71
+ *
72
+ * @function loadExternalDataSourceSchema
73
+ * @returns {Function} Compiled AJV validator function
74
+ * @throws {Error} If schema file cannot be loaded or compiled
75
+ *
76
+ * @example
77
+ * const validate = loadExternalDataSourceSchema();
78
+ * const valid = validate(data);
79
+ */
80
+ function loadExternalDataSourceSchema() {
81
+ if (externalDataSourceValidator) {
82
+ return externalDataSourceValidator;
83
+ }
84
+
85
+ const schemaPath = path.join(__dirname, '..', 'schema', 'external-datasource.schema.json');
86
+
87
+ if (!fs.existsSync(schemaPath)) {
88
+ throw new Error(`External datasource schema not found: ${schemaPath}`);
89
+ }
90
+
91
+ const schemaContent = fs.readFileSync(schemaPath, 'utf8');
92
+ let schema;
93
+
94
+ try {
95
+ schema = JSON.parse(schemaContent);
96
+ } catch (error) {
97
+ throw new Error(`Invalid JSON in external-datasource.schema.json: ${error.message}`);
98
+ }
99
+
100
+ // For draft-2020-12 schemas, we need to handle $schema differently
101
+ // Remove $schema if it's draft-2020-12 to avoid AJV issues
102
+ const schemaToCompile = { ...schema };
103
+ if (schemaToCompile.$schema && schemaToCompile.$schema.includes('2020-12')) {
104
+ // AJV v8 supports draft-2020-12 but may need the schema without $schema for compilation
105
+ delete schemaToCompile.$schema;
106
+ }
107
+
108
+ const ajv = new Ajv({ allErrors: true, strict: false, strictSchema: false });
109
+ externalDataSourceValidator = ajv.compile(schemaToCompile);
110
+
111
+ return externalDataSourceValidator;
112
+ }
113
+
114
+ /**
115
+ * Detects schema type from file content or path
116
+ * Attempts to identify if file is application, external-system, or external-datasource
117
+ *
118
+ * @function detectSchemaType
119
+ * @param {string} filePath - Path to the file
120
+ * @param {string} [content] - Optional file content (if not provided, will be read from file)
121
+ * @returns {string} Schema type: 'application' | 'external-system' | 'external-datasource'
122
+ * @throws {Error} If file cannot be read or parsed
123
+ *
124
+ * @example
125
+ * const type = detectSchemaType('./hubspot.json');
126
+ * // Returns: 'external-system'
127
+ */
128
+ function detectSchemaType(filePath, content) {
129
+ let fileContent = content;
130
+
131
+ // Read file if content not provided
132
+ if (!fileContent) {
133
+ if (!fs.existsSync(filePath)) {
134
+ throw new Error(`File not found: ${filePath}`);
135
+ }
136
+ fileContent = fs.readFileSync(filePath, 'utf8');
137
+ }
138
+
139
+ // Try to parse JSON
140
+ let parsed;
141
+ try {
142
+ parsed = JSON.parse(fileContent);
143
+ } catch (error) {
144
+ throw new Error(`Invalid JSON in file: ${error.message}`);
145
+ }
146
+
147
+ // Check for schema type indicators
148
+ // Check $id for schema type
149
+ if (parsed.$id) {
150
+ if (parsed.$id.includes('external-system')) {
151
+ return 'external-system';
152
+ }
153
+ if (parsed.$id.includes('external-datasource')) {
154
+ return 'external-datasource';
155
+ }
156
+ if (parsed.$id.includes('application-schema')) {
157
+ return 'application';
158
+ }
159
+ }
160
+
161
+ // Check title for schema type (works even without $id or $schema)
162
+ if (parsed.title) {
163
+ const titleLower = parsed.title.toLowerCase();
164
+ if (titleLower.includes('external system') || titleLower.includes('external-system') || titleLower.includes('external system configuration')) {
165
+ return 'external-system';
166
+ }
167
+ if (titleLower.includes('external data source') || titleLower.includes('external datasource') || titleLower.includes('external-datasource')) {
168
+ return 'external-datasource';
169
+ }
170
+ if (titleLower.includes('application')) {
171
+ return 'application';
172
+ }
173
+ }
174
+
175
+ // Check for required fields to determine type
176
+ // External system requires: key, displayName, description, type, authentication
177
+ if (parsed.key && parsed.displayName && parsed.type && parsed.authentication) {
178
+ // Check if it has systemKey (datasource) or not (system)
179
+ if (parsed.systemKey) {
180
+ return 'external-datasource';
181
+ }
182
+ // Check if type is one of external-system types
183
+ if (['openapi', 'mcp', 'custom'].includes(parsed.type)) {
184
+ return 'external-system';
185
+ }
186
+ }
187
+
188
+ // Check for datasource-specific fields
189
+ if (parsed.systemKey && parsed.entityKey && parsed.fieldMappings) {
190
+ return 'external-datasource';
191
+ }
192
+
193
+ // Check for application-specific fields
194
+ if (parsed.deploymentKey || (parsed.image && parsed.registryMode && parsed.port)) {
195
+ return 'application';
196
+ }
197
+
198
+ // Fallback: check filename pattern
199
+ const fileName = path.basename(filePath).toLowerCase();
200
+ if (fileName.includes('external-system') || fileName.includes('external_system')) {
201
+ return 'external-system';
202
+ }
203
+ if (fileName.includes('external-datasource') || fileName.includes('external_datasource') || fileName.includes('datasource')) {
204
+ return 'external-datasource';
205
+ }
206
+ if (fileName.includes('application') || fileName.includes('variables') || fileName.includes('deploy')) {
207
+ return 'application';
208
+ }
209
+
210
+ // Default to application if cannot determine
211
+ return 'application';
212
+ }
213
+
214
+ module.exports = {
215
+ loadExternalSystemSchema,
216
+ loadExternalDataSourceSchema,
217
+ detectSchemaType,
218
+ resetValidators
219
+ };
220
+
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Schema Resolution Utilities
3
+ *
4
+ * Resolves paths for external integration schemas from application configuration.
5
+ * Handles schemaBasePath resolution and external file discovery.
6
+ *
7
+ * @fileoverview Schema path resolution utilities for AI Fabrix Builder
8
+ * @author AI Fabrix Team
9
+ * @version 2.0.0
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const yaml = require('js-yaml');
15
+
16
+ /**
17
+ * Resolves schemaBasePath from application variables.yaml
18
+ * Supports both absolute and relative paths
19
+ *
20
+ * @async
21
+ * @function resolveSchemaBasePath
22
+ * @param {string} appName - Application name
23
+ * @returns {Promise<string>} Resolved absolute path to schema base directory
24
+ * @throws {Error} If variables.yaml not found, externalIntegration missing, or path invalid
25
+ *
26
+ * @example
27
+ * const basePath = await resolveSchemaBasePath('myapp');
28
+ * // Returns: '/path/to/builder/myapp/schemas'
29
+ */
30
+ async function resolveSchemaBasePath(appName) {
31
+ if (!appName || typeof appName !== 'string') {
32
+ throw new Error('App name is required and must be a string');
33
+ }
34
+
35
+ const variablesPath = path.join(process.cwd(), 'builder', appName, 'variables.yaml');
36
+
37
+ if (!fs.existsSync(variablesPath)) {
38
+ throw new Error(`variables.yaml not found: ${variablesPath}`);
39
+ }
40
+
41
+ const content = fs.readFileSync(variablesPath, 'utf8');
42
+ let variables;
43
+
44
+ try {
45
+ variables = yaml.load(content);
46
+ } catch (error) {
47
+ throw new Error(`Invalid YAML syntax in variables.yaml: ${error.message}`);
48
+ }
49
+
50
+ // Check if externalIntegration block exists
51
+ if (!variables.externalIntegration) {
52
+ throw new Error(`externalIntegration block not found in variables.yaml for app: ${appName}`);
53
+ }
54
+
55
+ if (!variables.externalIntegration.schemaBasePath) {
56
+ throw new Error(`schemaBasePath not found in externalIntegration block for app: ${appName}`);
57
+ }
58
+
59
+ const schemaBasePath = variables.externalIntegration.schemaBasePath;
60
+ const variablesDir = path.dirname(variablesPath);
61
+
62
+ // Resolve path (absolute or relative to variables.yaml location)
63
+ let resolvedPath;
64
+ if (path.isAbsolute(schemaBasePath)) {
65
+ resolvedPath = schemaBasePath;
66
+ } else {
67
+ resolvedPath = path.resolve(variablesDir, schemaBasePath);
68
+ }
69
+
70
+ // Normalize path
71
+ resolvedPath = path.normalize(resolvedPath);
72
+
73
+ // Validate path exists
74
+ if (!fs.existsSync(resolvedPath)) {
75
+ throw new Error(`Schema base path does not exist: ${resolvedPath}`);
76
+ }
77
+
78
+ if (!fs.statSync(resolvedPath).isDirectory()) {
79
+ throw new Error(`Schema base path is not a directory: ${resolvedPath}`);
80
+ }
81
+
82
+ return resolvedPath;
83
+ }
84
+
85
+ /**
86
+ * Resolves all external system and datasource files from application configuration
87
+ * Returns array of file paths with metadata
88
+ *
89
+ * @async
90
+ * @function resolveExternalFiles
91
+ * @param {string} appName - Application name
92
+ * @returns {Promise<Array<{path: string, type: 'system'|'datasource', fileName: string}>>} Array of resolved file paths with metadata
93
+ * @throws {Error} If files cannot be resolved or do not exist
94
+ *
95
+ * @example
96
+ * const files = await resolveExternalFiles('myapp');
97
+ * // Returns: [
98
+ * // { path: '/path/to/hubspot.json', type: 'system', fileName: 'hubspot.json' },
99
+ * // { path: '/path/to/hubspot-deal.json', type: 'datasource', fileName: 'hubspot-deal.json' }
100
+ * // ]
101
+ */
102
+ async function resolveExternalFiles(appName) {
103
+ if (!appName || typeof appName !== 'string') {
104
+ throw new Error('App name is required and must be a string');
105
+ }
106
+
107
+ const variablesPath = path.join(process.cwd(), 'builder', appName, 'variables.yaml');
108
+
109
+ if (!fs.existsSync(variablesPath)) {
110
+ throw new Error(`variables.yaml not found: ${variablesPath}`);
111
+ }
112
+
113
+ const content = fs.readFileSync(variablesPath, 'utf8');
114
+ let variables;
115
+
116
+ try {
117
+ variables = yaml.load(content);
118
+ } catch (error) {
119
+ throw new Error(`Invalid YAML syntax in variables.yaml: ${error.message}`);
120
+ }
121
+
122
+ // Check if externalIntegration block exists
123
+ if (!variables.externalIntegration) {
124
+ return []; // No external integration, return empty array
125
+ }
126
+
127
+ // Resolve schema base path
128
+ const schemaBasePath = await resolveSchemaBasePath(appName);
129
+ const resolvedFiles = [];
130
+
131
+ // Resolve systems files
132
+ if (variables.externalIntegration.systems && Array.isArray(variables.externalIntegration.systems)) {
133
+ for (const systemFile of variables.externalIntegration.systems) {
134
+ const systemPath = path.join(schemaBasePath, systemFile);
135
+ const normalizedPath = path.normalize(systemPath);
136
+
137
+ if (!fs.existsSync(normalizedPath)) {
138
+ throw new Error(`External system file not found: ${normalizedPath}`);
139
+ }
140
+
141
+ resolvedFiles.push({
142
+ path: normalizedPath,
143
+ type: 'system',
144
+ fileName: systemFile
145
+ });
146
+ }
147
+ }
148
+
149
+ // Resolve datasources files
150
+ if (variables.externalIntegration.dataSources && Array.isArray(variables.externalIntegration.dataSources)) {
151
+ for (const datasourceFile of variables.externalIntegration.dataSources) {
152
+ const datasourcePath = path.join(schemaBasePath, datasourceFile);
153
+ const normalizedPath = path.normalize(datasourcePath);
154
+
155
+ if (!fs.existsSync(normalizedPath)) {
156
+ throw new Error(`External datasource file not found: ${normalizedPath}`);
157
+ }
158
+
159
+ resolvedFiles.push({
160
+ path: normalizedPath,
161
+ type: 'datasource',
162
+ fileName: datasourceFile
163
+ });
164
+ }
165
+ }
166
+
167
+ return resolvedFiles;
168
+ }
169
+
170
+ module.exports = {
171
+ resolveSchemaBasePath,
172
+ resolveExternalFiles
173
+ };
174
+
@@ -13,10 +13,10 @@ const path = require('path');
13
13
  const yaml = require('js-yaml');
14
14
  const config = require('../config');
15
15
  const { buildHostnameToServiceMap, resolveUrlPort } = require('./secrets-utils');
16
- const { rewriteInfraEndpoints, getEnvHosts } = require('./env-endpoints');
16
+ const { rewriteInfraEndpoints, getEnvHosts, getServicePort, getServiceHost, getLocalhostOverride } = require('./env-endpoints');
17
17
  const { loadEnvConfig } = require('./env-config-loader');
18
- const { processEnvVariables } = require('./env-copy');
19
18
  const { updateContainerPortInEnvFile } = require('./env-ports');
19
+ const { buildEnvVarMap } = require('./env-map');
20
20
 
21
21
  /**
22
22
  * Interpolate ${VAR} occurrences with values from envVars map
@@ -262,6 +262,24 @@ async function adjustLocalEnvPortsInContent(envContent, variablesPath) {
262
262
  // Update infra endpoints with developer-id adjusted ports for local context
263
263
  updated = await rewriteInfraEndpoints(updated, 'local');
264
264
 
265
+ // Interpolate ${VAR} references created by rewriteInfraEndpoints
266
+ // Get the ports that were just set by rewriteInfraEndpoints for interpolation
267
+ const hostsForPorts = await getEnvHosts('local');
268
+ const redisPort = await getServicePort('REDIS_PORT', 'redis', hostsForPorts, 'local');
269
+ const dbPort = await getServicePort('DB_PORT', 'postgres', hostsForPorts, 'local');
270
+ const localhostOverride = getLocalhostOverride('local');
271
+ const redisHost = getServiceHost(hostsForPorts.REDIS_HOST, 'local', 'localhost', localhostOverride);
272
+ const dbHost = getServiceHost(hostsForPorts.DB_HOST, 'local', 'localhost', localhostOverride);
273
+
274
+ // Build envVars map and ensure it has the correct values
275
+ const envVars = await buildEnvVarMap('local', null, devIdNum);
276
+ // Override with the actual values that were just set by rewriteInfraEndpoints
277
+ envVars.REDIS_HOST = redisHost;
278
+ envVars.REDIS_PORT = String(redisPort);
279
+ envVars.DB_HOST = dbHost;
280
+ envVars.DB_PORT = String(dbPort);
281
+ updated = interpolateEnvVars(updated, envVars);
282
+
265
283
  return updated;
266
284
  }
267
285
 
@@ -276,6 +294,29 @@ function readYamlAtPath(filePath) {
276
294
  return yaml.load(content);
277
295
  }
278
296
 
297
+ /**
298
+ * Merge a single secret value from canonical into result
299
+ * @function mergeSecretValue
300
+ * @param {Object} result - Result object to merge into
301
+ * @param {string} key - Secret key
302
+ * @param {*} canonicalValue - Value from canonical secrets
303
+ */
304
+ function mergeSecretValue(result, key, canonicalValue) {
305
+ const currentValue = result[key];
306
+ // Fill missing, empty, or undefined values
307
+ if (!(key in result) || currentValue === undefined || currentValue === null || currentValue === '') {
308
+ result[key] = canonicalValue;
309
+ return;
310
+ }
311
+ // Only replace values that are encrypted (have secure:// prefix)
312
+ // Plaintext values (no secure://) are used as-is
313
+ if (typeof currentValue === 'string' && typeof canonicalValue === 'string') {
314
+ if (currentValue.startsWith('secure://')) {
315
+ result[key] = canonicalValue;
316
+ }
317
+ }
318
+ }
319
+
279
320
  /**
280
321
  * Apply canonical secrets path override if configured and file exists
281
322
  * @async
@@ -287,21 +328,29 @@ async function applyCanonicalSecretsOverride(currentSecrets) {
287
328
  let mergedSecrets = currentSecrets || {};
288
329
  try {
289
330
  const canonicalPath = await config.getSecretsPath();
290
- if (canonicalPath) {
291
- const resolvedCanonical = path.isAbsolute(canonicalPath)
292
- ? canonicalPath
293
- : path.resolve(process.cwd(), canonicalPath);
294
- if (fs.existsSync(resolvedCanonical)) {
295
- const configSecrets = readYamlAtPath(resolvedCanonical);
296
- // Apply canonical secrets as a fallback source:
297
- // - Do NOT override any existing keys from user/build
298
- // - Add only missing keys from canonical path
299
- if (configSecrets && typeof configSecrets === 'object') {
300
- const result = { ...configSecrets, ...mergedSecrets };
301
- mergedSecrets = result;
302
- }
303
- }
331
+ if (!canonicalPath) {
332
+ return mergedSecrets;
333
+ }
334
+ const resolvedCanonical = path.isAbsolute(canonicalPath)
335
+ ? canonicalPath
336
+ : path.resolve(process.cwd(), canonicalPath);
337
+ if (!fs.existsSync(resolvedCanonical)) {
338
+ return mergedSecrets;
339
+ }
340
+ const configSecrets = readYamlAtPath(resolvedCanonical);
341
+ if (!configSecrets || typeof configSecrets !== 'object') {
342
+ return mergedSecrets;
343
+ }
344
+ // Apply canonical secrets as a fallback source:
345
+ // - Do NOT override any existing keys from user/build
346
+ // - Add only missing keys from canonical path
347
+ // - Also fill in empty/undefined values from canonical path
348
+ // - Replace encrypted values (secure://) with canonical plaintext
349
+ const result = { ...mergedSecrets };
350
+ for (const [key, canonicalValue] of Object.entries(configSecrets)) {
351
+ mergeSecretValue(result, key, canonicalValue);
304
352
  }
353
+ mergedSecrets = result;
305
354
  } catch {
306
355
  // ignore and fall through
307
356
  }
@@ -351,7 +400,6 @@ module.exports = {
351
400
  replaceKvInContent,
352
401
  resolveServicePortsInEnvContent,
353
402
  loadEnvTemplate,
354
- processEnvVariables,
355
403
  updateContainerPortInEnvFile,
356
404
  adjustLocalEnvPortsInContent,
357
405
  readYamlAtPath,
@@ -86,6 +86,17 @@ function isTokenExpired(expiresAt) {
86
86
  return config.isTokenExpired(expiresAt);
87
87
  }
88
88
 
89
+ /**
90
+ * Check if token should be refreshed proactively
91
+ * Returns true if token is within 15 minutes of expiry
92
+ * This helps keep Keycloak sessions alive by refreshing before the SSO Session Idle timeout (30 minutes)
93
+ * @param {string} expiresAt - ISO timestamp string
94
+ * @returns {boolean} True if token should be refreshed proactively
95
+ */
96
+ function shouldRefreshToken(expiresAt) {
97
+ return config.shouldRefreshToken(expiresAt);
98
+ }
99
+
89
100
  /**
90
101
  * Refresh client token using credentials from secrets.local.yaml
91
102
  * Gets new token from API and saves it to config.yaml
@@ -184,7 +195,7 @@ async function getOrRefreshClientToken(environment, appName, controllerUrl) {
184
195
  * @param {string} controllerUrl - Controller URL
185
196
  * @param {string} refreshToken - Refresh token
186
197
  * @returns {Promise<{token: string, refreshToken: string, expiresAt: string}>} New token info
187
- * @throws {Error} If refresh fails
198
+ * @throws {Error} If refresh fails or refresh token is expired/invalid
188
199
  */
189
200
  async function refreshDeviceToken(controllerUrl, refreshToken) {
190
201
  if (!controllerUrl || typeof controllerUrl !== 'string') {
@@ -194,27 +205,41 @@ async function refreshDeviceToken(controllerUrl, refreshToken) {
194
205
  throw new Error('Refresh token is required');
195
206
  }
196
207
 
197
- // Call API refresh endpoint
198
- const tokenResponse = await apiRefreshDeviceToken(controllerUrl, refreshToken);
208
+ try {
209
+ // Call API refresh endpoint
210
+ const tokenResponse = await apiRefreshDeviceToken(controllerUrl, refreshToken);
199
211
 
200
- const token = tokenResponse.access_token;
201
- const newRefreshToken = tokenResponse.refresh_token || refreshToken; // Use new refresh token if provided, otherwise keep old one
202
- const expiresIn = tokenResponse.expires_in || 3600;
203
- const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString();
212
+ const token = tokenResponse.access_token;
213
+ const newRefreshToken = tokenResponse.refresh_token || refreshToken; // Use new refresh token if provided, otherwise keep old one
214
+ const expiresIn = tokenResponse.expires_in || 3600;
215
+ const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString();
204
216
 
205
- // Save new token and refresh token to config
206
- await config.saveDeviceToken(controllerUrl, token, newRefreshToken, expiresAt);
217
+ // Save new token and refresh token to config
218
+ await config.saveDeviceToken(controllerUrl, token, newRefreshToken, expiresAt);
207
219
 
208
- return {
209
- token,
210
- refreshToken: newRefreshToken,
211
- expiresAt
212
- };
220
+ return {
221
+ token,
222
+ refreshToken: newRefreshToken,
223
+ expiresAt
224
+ };
225
+ } catch (error) {
226
+ // Check if error indicates refresh token expiry (case-insensitive)
227
+ const errorMessage = (error.message || String(error)).toLowerCase();
228
+ if (errorMessage.includes('expired') ||
229
+ errorMessage.includes('invalid') ||
230
+ errorMessage.includes('401') ||
231
+ errorMessage.includes('unauthorized')) {
232
+ throw new Error('Refresh token has expired. Please login again using: aifabrix login');
233
+ }
234
+ // Re-throw other errors as-is
235
+ throw error;
236
+ }
213
237
  }
214
238
 
215
239
  /**
216
240
  * Get or refresh device token for controller
217
- * Checks if token exists and is valid, refreshes if expired using refresh token
241
+ * Checks if token exists and is valid, refreshes proactively if within 15 minutes of expiry
242
+ * This helps keep Keycloak sessions alive by refreshing before the SSO Session Idle timeout (30 minutes)
218
243
  * @param {string} controllerUrl - Controller URL
219
244
  * @returns {Promise<{token: string, controller: string}|null>} Token and controller URL, or null if not available
220
245
  */
@@ -226,18 +251,23 @@ async function getOrRefreshDeviceToken(controllerUrl) {
226
251
  return null;
227
252
  }
228
253
 
229
- // Check if token is expired
230
- if (!isTokenExpired(tokenInfo.expiresAt)) {
231
- // Token is valid
254
+ // Check if token should be refreshed proactively (within 15 minutes of expiry)
255
+ // This ensures we refresh before Keycloak's SSO Session Idle timeout (30 minutes)
256
+ const needsRefresh = shouldRefreshToken(tokenInfo.expiresAt);
257
+
258
+ if (!needsRefresh) {
259
+ // Token is valid and doesn't need refresh yet
232
260
  return {
233
261
  token: tokenInfo.token,
234
262
  controller: tokenInfo.controller
235
263
  };
236
264
  }
237
265
 
238
- // Token is expired, try to refresh if refresh token exists
266
+ // Token needs refresh (expired or within 15 minutes of expiry)
267
+ // Try to refresh if refresh token exists
239
268
  if (!tokenInfo.refreshToken) {
240
269
  // No refresh token available
270
+ logger.warn('Access token expired and no refresh token available. Please login again using: aifabrix login');
241
271
  return null;
242
272
  }
243
273
 
@@ -248,8 +278,13 @@ async function getOrRefreshDeviceToken(controllerUrl) {
248
278
  controller: controllerUrl
249
279
  };
250
280
  } catch (error) {
251
- // Refresh failed, return null
252
- logger.warn(`Failed to refresh device token: ${error.message}`);
281
+ // Refresh failed - check if it's a refresh token expiry
282
+ const errorMessage = error.message || String(error);
283
+ if (errorMessage.includes('Refresh token has expired')) {
284
+ logger.warn(`Refresh token expired: ${errorMessage}`);
285
+ } else {
286
+ logger.warn(`Failed to refresh device token: ${errorMessage}`);
287
+ }
253
288
  return null;
254
289
  }
255
290
  }
@@ -370,6 +405,7 @@ module.exports = {
370
405
  getDeviceToken,
371
406
  getClientToken,
372
407
  isTokenExpired,
408
+ shouldRefreshToken,
373
409
  refreshClientToken,
374
410
  refreshDeviceToken,
375
411
  loadClientCredentials,