@aifabrix/builder 2.1.7 → 2.3.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.
Files changed (43) hide show
  1. package/lib/app-deploy.js +73 -29
  2. package/lib/app-list.js +132 -0
  3. package/lib/app-readme.js +11 -4
  4. package/lib/app-register.js +435 -0
  5. package/lib/app-rotate-secret.js +164 -0
  6. package/lib/app-run.js +98 -84
  7. package/lib/app.js +13 -0
  8. package/lib/audit-logger.js +195 -15
  9. package/lib/build.js +155 -42
  10. package/lib/cli.js +104 -8
  11. package/lib/commands/app.js +8 -391
  12. package/lib/commands/login.js +130 -36
  13. package/lib/commands/secure.js +260 -0
  14. package/lib/config.js +315 -4
  15. package/lib/deployer.js +221 -183
  16. package/lib/infra.js +177 -112
  17. package/lib/push.js +34 -7
  18. package/lib/secrets.js +89 -23
  19. package/lib/templates.js +1 -1
  20. package/lib/utils/api-error-handler.js +465 -0
  21. package/lib/utils/api.js +165 -16
  22. package/lib/utils/auth-headers.js +84 -0
  23. package/lib/utils/build-copy.js +162 -0
  24. package/lib/utils/cli-utils.js +49 -3
  25. package/lib/utils/compose-generator.js +57 -16
  26. package/lib/utils/deployment-errors.js +90 -0
  27. package/lib/utils/deployment-validation.js +60 -0
  28. package/lib/utils/dev-config.js +83 -0
  29. package/lib/utils/docker-build.js +24 -0
  30. package/lib/utils/env-template.js +30 -10
  31. package/lib/utils/health-check.js +18 -1
  32. package/lib/utils/infra-containers.js +101 -0
  33. package/lib/utils/local-secrets.js +0 -2
  34. package/lib/utils/secrets-encryption.js +203 -0
  35. package/lib/utils/secrets-path.js +22 -3
  36. package/lib/utils/token-manager.js +381 -0
  37. package/package.json +2 -2
  38. package/templates/applications/README.md.hbs +155 -23
  39. package/templates/applications/miso-controller/Dockerfile +7 -119
  40. package/templates/infra/compose.yaml.hbs +93 -0
  41. package/templates/python/docker-compose.hbs +25 -17
  42. package/templates/typescript/docker-compose.hbs +25 -17
  43. package/test-output.txt +0 -5431
@@ -0,0 +1,203 @@
1
+ /**
2
+ * AI Fabrix Builder Secrets Encryption Utilities
3
+ *
4
+ * This module provides encryption and decryption functions for secrets
5
+ * using AES-256-GCM algorithm for ISO 27001 compliance.
6
+ *
7
+ * @fileoverview Secrets encryption utilities for AI Fabrix Builder
8
+ * @author AI Fabrix Team
9
+ * @version 2.0.0
10
+ */
11
+
12
+ const crypto = require('crypto');
13
+
14
+ const ALGORITHM = 'aes-256-gcm';
15
+ const IV_LENGTH = 12; // 96 bits for GCM
16
+ const AUTH_TAG_LENGTH = 16; // 128 bits
17
+ const KEY_LENGTH = 32; // 256 bits for AES-256
18
+
19
+ /**
20
+ * Validates encryption key format
21
+ * Key must be 32 bytes (256 bits) for AES-256
22
+ * Accepts hex string (64 chars) or base64 string (44 chars)
23
+ *
24
+ * @function validateEncryptionKey
25
+ * @param {string} key - Encryption key to validate
26
+ * @returns {boolean} True if key is valid
27
+ * @throws {Error} If key format is invalid
28
+ */
29
+ function validateEncryptionKey(key) {
30
+ if (!key || typeof key !== 'string') {
31
+ throw new Error('Encryption key is required and must be a string');
32
+ }
33
+
34
+ // Try to parse as hex (64 characters = 32 bytes)
35
+ if (key.length === 64 && /^[0-9a-fA-F]+$/.test(key)) {
36
+ return true;
37
+ }
38
+
39
+ // Try to parse as base64 (44 characters = 32 bytes)
40
+ if (key.length === 44) {
41
+ try {
42
+ const buffer = Buffer.from(key, 'base64');
43
+ if (buffer.length === KEY_LENGTH) {
44
+ return true;
45
+ }
46
+ } catch (error) {
47
+ // Not valid base64
48
+ }
49
+ }
50
+
51
+ throw new Error(`Encryption key must be 32 bytes (64 hex characters or 44 base64 characters). Got ${key.length} characters`);
52
+ }
53
+
54
+ /**
55
+ * Normalizes encryption key to Buffer
56
+ * Converts hex or base64 string to 32-byte buffer
57
+ *
58
+ * @function normalizeKey
59
+ * @param {string} key - Encryption key (hex or base64)
60
+ * @returns {Buffer} 32-byte key buffer
61
+ */
62
+ function normalizeKey(key) {
63
+ validateEncryptionKey(key);
64
+
65
+ // Try hex first (64 characters)
66
+ if (key.length === 64 && /^[0-9a-fA-F]+$/.test(key)) {
67
+ return Buffer.from(key, 'hex');
68
+ }
69
+
70
+ // Try base64 (44 characters)
71
+ if (key.length === 44) {
72
+ const buffer = Buffer.from(key, 'base64');
73
+ if (buffer.length === KEY_LENGTH) {
74
+ return buffer;
75
+ }
76
+ }
77
+
78
+ throw new Error('Invalid encryption key format');
79
+ }
80
+
81
+ /**
82
+ * Encrypts a secret value using AES-256-GCM
83
+ * Returns encrypted value in format: secure://<iv>:<ciphertext>:<authTag>
84
+ * All components are base64 encoded
85
+ *
86
+ * @function encryptSecret
87
+ * @param {string} value - Plaintext secret value to encrypt
88
+ * @param {string} key - Encryption key (hex or base64, 32 bytes)
89
+ * @returns {string} Encrypted value with secure:// prefix
90
+ * @throws {Error} If encryption fails or key is invalid
91
+ *
92
+ * @example
93
+ * const encrypted = encryptSecret('my-secret', 'a1b2c3...');
94
+ * // Returns: 'secure://<iv>:<ciphertext>:<authTag>'
95
+ */
96
+ function encryptSecret(value, key) {
97
+ if (typeof value !== 'string') {
98
+ throw new Error('Value is required and must be a string');
99
+ }
100
+
101
+ const keyBuffer = normalizeKey(key);
102
+
103
+ // Generate random IV
104
+ const iv = crypto.randomBytes(IV_LENGTH);
105
+
106
+ // Create cipher
107
+ const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv);
108
+
109
+ // Encrypt
110
+ let ciphertext = cipher.update(value, 'utf8', 'base64');
111
+ ciphertext += cipher.final('base64');
112
+
113
+ // Get authentication tag
114
+ const authTag = cipher.getAuthTag();
115
+
116
+ // Format: secure://<iv>:<ciphertext>:<authTag>
117
+ return `secure://${iv.toString('base64')}:${ciphertext}:${authTag.toString('base64')}`;
118
+ }
119
+
120
+ /**
121
+ * Decrypts an encrypted secret value
122
+ * Handles secure:// prefixed values and extracts IV, ciphertext, and auth tag
123
+ *
124
+ * @function decryptSecret
125
+ * @param {string} encryptedValue - Encrypted value with secure:// prefix
126
+ * @param {string} key - Encryption key (hex or base64, 32 bytes)
127
+ * @returns {string} Decrypted plaintext value
128
+ * @throws {Error} If decryption fails, key is invalid, or format is incorrect
129
+ *
130
+ * @example
131
+ * const decrypted = decryptSecret('secure://<iv>:<ciphertext>:<authTag>', 'a1b2c3...');
132
+ * // Returns: 'my-secret'
133
+ */
134
+ function decryptSecret(encryptedValue, key) {
135
+ if (!encryptedValue || typeof encryptedValue !== 'string') {
136
+ throw new Error('Encrypted value is required and must be a string');
137
+ }
138
+
139
+ if (!encryptedValue.startsWith('secure://')) {
140
+ throw new Error('Encrypted value must start with secure:// prefix');
141
+ }
142
+
143
+ const keyBuffer = normalizeKey(key);
144
+
145
+ // Remove secure:// prefix
146
+ const parts = encryptedValue.substring(9).split(':');
147
+ if (parts.length !== 3) {
148
+ throw new Error('Invalid encrypted value format. Expected: secure://<iv>:<ciphertext>:<authTag>');
149
+ }
150
+
151
+ const [ivBase64, ciphertext, authTagBase64] = parts;
152
+
153
+ try {
154
+ // Decode IV and auth tag
155
+ const iv = Buffer.from(ivBase64, 'base64');
156
+ const authTag = Buffer.from(authTagBase64, 'base64');
157
+
158
+ // Validate lengths
159
+ if (iv.length !== IV_LENGTH) {
160
+ throw new Error(`Invalid IV length: expected ${IV_LENGTH} bytes, got ${iv.length}`);
161
+ }
162
+
163
+ if (authTag.length !== AUTH_TAG_LENGTH) {
164
+ throw new Error(`Invalid auth tag length: expected ${AUTH_TAG_LENGTH} bytes, got ${authTag.length}`);
165
+ }
166
+
167
+ // Create decipher
168
+ const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv);
169
+ decipher.setAuthTag(authTag);
170
+
171
+ // Decrypt
172
+ let plaintext = decipher.update(ciphertext, 'base64', 'utf8');
173
+ plaintext += decipher.final('utf8');
174
+
175
+ return plaintext;
176
+ } catch (error) {
177
+ // Don't expose sensitive details in error messages
178
+ if (error.message.includes('Unsupported state') || error.message.includes('bad decrypt')) {
179
+ throw new Error('Decryption failed: invalid key or corrupted data');
180
+ }
181
+ throw error;
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Checks if a value is encrypted (starts with secure://)
187
+ *
188
+ * @function isEncrypted
189
+ * @param {string} value - Value to check
190
+ * @returns {boolean} True if value is encrypted
191
+ */
192
+ function isEncrypted(value) {
193
+ return typeof value === 'string' && value.startsWith('secure://');
194
+ }
195
+
196
+ module.exports = {
197
+ encryptSecret,
198
+ decryptSecret,
199
+ isEncrypted,
200
+ validateEncryptionKey,
201
+ normalizeKey
202
+ };
203
+
@@ -13,6 +13,7 @@ const fs = require('fs');
13
13
  const path = require('path');
14
14
  const os = require('os');
15
15
  const yaml = require('js-yaml');
16
+ const config = require('../config');
16
17
 
17
18
  /**
18
19
  * Resolves secrets file path (backward compatibility)
@@ -56,14 +57,17 @@ function resolveSecretsPath(secretsPath) {
56
57
  /**
57
58
  * Determines the actual secrets file paths that loadSecrets would use
58
59
  * Mirrors the cascading lookup logic from loadSecrets
60
+ * Checks config.yaml for general secrets-path as fallback
61
+ *
62
+ * @async
59
63
  * @function getActualSecretsPath
60
64
  * @param {string} [secretsPath] - Path to secrets file (optional)
61
65
  * @param {string} [appName] - Application name (optional, for variables.yaml lookup)
62
- * @returns {Object} Object with userPath and buildPath (if configured)
66
+ * @returns {Promise<Object>} Object with userPath and buildPath (if configured)
63
67
  * @returns {string} returns.userPath - User's secrets file path (~/.aifabrix/secrets.local.yaml)
64
- * @returns {string|null} returns.buildPath - App's build.secrets file path (if configured in variables.yaml)
68
+ * @returns {string|null} returns.buildPath - App's build.secrets file path (if configured in variables.yaml or config.yaml)
65
69
  */
66
- function getActualSecretsPath(secretsPath, appName) {
70
+ async function getActualSecretsPath(secretsPath, appName) {
67
71
  // If explicit path provided, use it (backward compatibility)
68
72
  if (secretsPath) {
69
73
  const resolvedPath = resolveSecretsPath(secretsPath);
@@ -97,6 +101,21 @@ function getActualSecretsPath(secretsPath, appName) {
97
101
  }
98
102
  }
99
103
 
104
+ // If no build.secrets found in variables.yaml, check config.yaml for general secrets-path
105
+ if (!buildSecretsPath) {
106
+ try {
107
+ const generalSecretsPath = await config.getSecretsPath();
108
+ if (generalSecretsPath) {
109
+ // Resolve relative paths from current working directory
110
+ buildSecretsPath = path.isAbsolute(generalSecretsPath)
111
+ ? generalSecretsPath
112
+ : path.resolve(process.cwd(), generalSecretsPath);
113
+ }
114
+ } catch (error) {
115
+ // Ignore errors, continue
116
+ }
117
+ }
118
+
100
119
  // Return both paths (even if files don't exist) for error messages
101
120
  return {
102
121
  userPath: userSecretsPath,
@@ -0,0 +1,381 @@
1
+ /**
2
+ * AI Fabrix Builder Token Management Utilities
3
+ *
4
+ * Centralized token management for device and client credentials tokens
5
+ * Handles token retrieval, expiration checking, and refresh logic
6
+ *
7
+ * @fileoverview Token management 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 os = require('os');
15
+ const yaml = require('js-yaml');
16
+ const config = require('../config');
17
+ const { makeApiCall, refreshDeviceToken: apiRefreshDeviceToken } = require('./api');
18
+ const logger = require('./logger');
19
+
20
+ const SECRETS_FILE = path.join(os.homedir(), '.aifabrix', 'secrets.local.yaml');
21
+
22
+ /**
23
+ * Load client credentials from secrets.local.yaml
24
+ * Reads using pattern: <app-name>-client-idKeyVault and <app-name>-client-secretKeyVault
25
+ * @param {string} appName - Application name
26
+ * @returns {Promise<{clientId: string, clientSecret: string}|null>} Credentials or null if not found
27
+ */
28
+ async function loadClientCredentials(appName) {
29
+ if (!appName || typeof appName !== 'string') {
30
+ throw new Error('App name is required and must be a string');
31
+ }
32
+
33
+ try {
34
+ if (!fs.existsSync(SECRETS_FILE)) {
35
+ return null;
36
+ }
37
+
38
+ const content = fs.readFileSync(SECRETS_FILE, 'utf8');
39
+ const secrets = yaml.load(content) || {};
40
+
41
+ const clientIdKey = `${appName}-client-idKeyVault`;
42
+ const clientSecretKey = `${appName}-client-secretKeyVault`;
43
+
44
+ const clientId = secrets[clientIdKey];
45
+ const clientSecret = secrets[clientSecretKey];
46
+
47
+ if (!clientId || !clientSecret) {
48
+ return null;
49
+ }
50
+
51
+ return {
52
+ clientId: clientId,
53
+ clientSecret: clientSecret
54
+ };
55
+ } catch (error) {
56
+ logger.warn(`Failed to load credentials from secrets.local.yaml: ${error.message}`);
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Get device token for controller
63
+ * @param {string} controllerUrl - Controller URL
64
+ * @returns {Promise<{controller: string, token: string, refreshToken: string, expiresAt: string}|null>} Device token info or null
65
+ */
66
+ async function getDeviceToken(controllerUrl) {
67
+ return await config.getDeviceToken(controllerUrl);
68
+ }
69
+
70
+ /**
71
+ * Get client token for environment and app
72
+ * @param {string} environment - Environment key
73
+ * @param {string} appName - Application name
74
+ * @returns {Promise<{controller: string, token: string, expiresAt: string}|null>} Client token info or null
75
+ */
76
+ async function getClientToken(environment, appName) {
77
+ return await config.getClientToken(environment, appName);
78
+ }
79
+
80
+ /**
81
+ * Check if token is expired
82
+ * @param {string} expiresAt - ISO timestamp string
83
+ * @returns {boolean} True if token is expired
84
+ */
85
+ function isTokenExpired(expiresAt) {
86
+ return config.isTokenExpired(expiresAt);
87
+ }
88
+
89
+ /**
90
+ * Refresh client token using credentials from secrets.local.yaml
91
+ * Gets new token from API and saves it to config.yaml
92
+ * @param {string} environment - Environment key
93
+ * @param {string} appName - Application name
94
+ * @param {string} controllerUrl - Controller URL
95
+ * @param {string} [clientId] - Optional client ID (if not provided, loads from secrets.local.yaml)
96
+ * @param {string} [clientSecret] - Optional client secret (if not provided, loads from secrets.local.yaml)
97
+ * @returns {Promise<{token: string, expiresAt: string}>} New token and expiration
98
+ * @throws {Error} If credentials are missing or token refresh fails
99
+ */
100
+ async function refreshClientToken(environment, appName, controllerUrl, clientId, clientSecret) {
101
+ if (!environment || typeof environment !== 'string') {
102
+ throw new Error('Environment is required and must be a string');
103
+ }
104
+ if (!appName || typeof appName !== 'string') {
105
+ throw new Error('App name is required and must be a string');
106
+ }
107
+ if (!controllerUrl || typeof controllerUrl !== 'string') {
108
+ throw new Error('Controller URL is required and must be a string');
109
+ }
110
+
111
+ // Load credentials if not provided
112
+ let credentials = null;
113
+ if (clientId && clientSecret) {
114
+ credentials = { clientId, clientSecret };
115
+ } else {
116
+ credentials = await loadClientCredentials(appName);
117
+ if (!credentials) {
118
+ throw new Error(`Client credentials not found for app '${appName}'. Add them to ~/.aifabrix/secrets.local.yaml as '${appName}-client-idKeyVault' and '${appName}-client-secretKeyVault'`);
119
+ }
120
+ }
121
+
122
+ // Call login API to get new token
123
+ const response = await makeApiCall(`${controllerUrl}/api/v1/auth/token`, {
124
+ method: 'POST',
125
+ headers: {
126
+ 'Content-Type': 'application/json',
127
+ 'x-client-id': credentials.clientId,
128
+ 'x-client-secret': credentials.clientSecret
129
+ }
130
+ });
131
+
132
+ if (!response.success) {
133
+ throw new Error(`Failed to refresh token: ${response.error || 'Unknown error'}`);
134
+ }
135
+
136
+ const responseData = response.data;
137
+ if (!responseData || !responseData.token) {
138
+ throw new Error('Invalid response: missing token');
139
+ }
140
+
141
+ const token = responseData.token;
142
+ // Calculate expiration (default to 24 hours if not provided)
143
+ const expiresIn = responseData.expiresIn || 86400;
144
+ const expiresAt = responseData.expiresAt || new Date(Date.now() + expiresIn * 1000).toISOString();
145
+
146
+ // Save token to config.yaml (NEVER save credentials)
147
+ await config.saveClientToken(environment, appName, controllerUrl, token, expiresAt);
148
+
149
+ return { token, expiresAt };
150
+ }
151
+
152
+ /**
153
+ * Get or refresh client token for environment and app
154
+ * Checks if token exists and is valid, refreshes if expired
155
+ * @param {string} environment - Environment key
156
+ * @param {string} appName - Application name
157
+ * @param {string} controllerUrl - Controller URL
158
+ * @returns {Promise<{token: string, controller: string}>} Token and controller URL
159
+ * @throws {Error} If token cannot be retrieved or refreshed
160
+ */
161
+ async function getOrRefreshClientToken(environment, appName, controllerUrl) {
162
+ // Try to get existing token
163
+ const tokenInfo = await getClientToken(environment, appName);
164
+
165
+ if (tokenInfo && tokenInfo.controller === controllerUrl && !isTokenExpired(tokenInfo.expiresAt)) {
166
+ // Token exists, is for correct controller, and is not expired
167
+ return {
168
+ token: tokenInfo.token,
169
+ controller: tokenInfo.controller
170
+ };
171
+ }
172
+
173
+ // Token missing or expired, refresh it
174
+ const refreshed = await refreshClientToken(environment, appName, controllerUrl);
175
+ return {
176
+ token: refreshed.token,
177
+ controller: controllerUrl
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Refresh device token using refresh token
183
+ * Calls API refresh endpoint and saves new token to config
184
+ * @param {string} controllerUrl - Controller URL
185
+ * @param {string} refreshToken - Refresh token
186
+ * @returns {Promise<{token: string, refreshToken: string, expiresAt: string}>} New token info
187
+ * @throws {Error} If refresh fails
188
+ */
189
+ async function refreshDeviceToken(controllerUrl, refreshToken) {
190
+ if (!controllerUrl || typeof controllerUrl !== 'string') {
191
+ throw new Error('Controller URL is required');
192
+ }
193
+ if (!refreshToken || typeof refreshToken !== 'string') {
194
+ throw new Error('Refresh token is required');
195
+ }
196
+
197
+ // Call API refresh endpoint
198
+ const tokenResponse = await apiRefreshDeviceToken(controllerUrl, refreshToken);
199
+
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();
204
+
205
+ // Save new token and refresh token to config
206
+ await config.saveDeviceToken(controllerUrl, token, newRefreshToken, expiresAt);
207
+
208
+ return {
209
+ token,
210
+ refreshToken: newRefreshToken,
211
+ expiresAt
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Get or refresh device token for controller
217
+ * Checks if token exists and is valid, refreshes if expired using refresh token
218
+ * @param {string} controllerUrl - Controller URL
219
+ * @returns {Promise<{token: string, controller: string}|null>} Token and controller URL, or null if not available
220
+ */
221
+ async function getOrRefreshDeviceToken(controllerUrl) {
222
+ // Try to get existing token
223
+ const tokenInfo = await getDeviceToken(controllerUrl);
224
+
225
+ if (!tokenInfo) {
226
+ return null;
227
+ }
228
+
229
+ // Check if token is expired
230
+ if (!isTokenExpired(tokenInfo.expiresAt)) {
231
+ // Token is valid
232
+ return {
233
+ token: tokenInfo.token,
234
+ controller: tokenInfo.controller
235
+ };
236
+ }
237
+
238
+ // Token is expired, try to refresh if refresh token exists
239
+ if (!tokenInfo.refreshToken) {
240
+ // No refresh token available
241
+ return null;
242
+ }
243
+
244
+ try {
245
+ const refreshed = await refreshDeviceToken(controllerUrl, tokenInfo.refreshToken);
246
+ return {
247
+ token: refreshed.token,
248
+ controller: controllerUrl
249
+ };
250
+ } catch (error) {
251
+ // Refresh failed, return null
252
+ logger.warn(`Failed to refresh device token: ${error.message}`);
253
+ return null;
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Get deployment authentication configuration with priority:
259
+ * 1. Device token (Bearer) - for user-level audit tracking (preferred)
260
+ * 2. Client token (Bearer) - for application-level authentication
261
+ * 3. Client credentials (x-client-id/x-client-secret) - direct credential authentication
262
+ *
263
+ * @param {string} controllerUrl - Controller URL
264
+ * @param {string} environment - Environment key
265
+ * @param {string} appName - Application name
266
+ * @returns {Promise<{type: 'bearer'|'credentials', token?: string, clientId?: string, clientSecret?: string, controller: string}>} Auth configuration
267
+ * @throws {Error} If no authentication method is available
268
+ */
269
+ async function getDeploymentAuth(controllerUrl, environment, appName) {
270
+ if (!controllerUrl || typeof controllerUrl !== 'string') {
271
+ throw new Error('Controller URL is required');
272
+ }
273
+ if (!environment || typeof environment !== 'string') {
274
+ throw new Error('Environment is required');
275
+ }
276
+ if (!appName || typeof appName !== 'string') {
277
+ throw new Error('App name is required');
278
+ }
279
+
280
+ // Priority 1: Try device token (for user-level audit)
281
+ const deviceToken = await getOrRefreshDeviceToken(controllerUrl);
282
+ if (deviceToken && deviceToken.token) {
283
+ return {
284
+ type: 'bearer',
285
+ token: deviceToken.token,
286
+ controller: deviceToken.controller
287
+ };
288
+ }
289
+
290
+ // Priority 2: Try client token (application-level)
291
+ try {
292
+ const clientToken = await getOrRefreshClientToken(environment, appName, controllerUrl);
293
+ if (clientToken && clientToken.token) {
294
+ return {
295
+ type: 'bearer',
296
+ token: clientToken.token,
297
+ controller: clientToken.controller
298
+ };
299
+ }
300
+ } catch (error) {
301
+ // Client token unavailable, continue to credentials
302
+ logger.warn(`Client token unavailable: ${error.message}`);
303
+ }
304
+
305
+ // Priority 3: Use client credentials directly
306
+ const credentials = await loadClientCredentials(appName);
307
+ if (credentials && credentials.clientId && credentials.clientSecret) {
308
+ return {
309
+ type: 'credentials',
310
+ clientId: credentials.clientId,
311
+ clientSecret: credentials.clientSecret,
312
+ controller: controllerUrl
313
+ };
314
+ }
315
+
316
+ throw new Error(`No authentication method available. Run 'aifabrix login' for device token, or add credentials to ~/.aifabrix/secrets.local.yaml as '${appName}-client-idKeyVault' and '${appName}-client-secretKeyVault'`);
317
+ }
318
+
319
+ /**
320
+ * Extracts client credentials from authConfig, loading from secrets if needed
321
+ * Used for validation and deployment endpoints that require clientId/clientSecret
322
+ * @async
323
+ * @param {Object} authConfig - Authentication configuration
324
+ * @param {string} appKey - Application key for loading credentials
325
+ * @param {string} envKey - Environment key
326
+ * @param {Object} options - Options with controllerId
327
+ * @returns {Promise<{clientId: string, clientSecret: string}>} Client credentials
328
+ * @throws {Error} If credentials cannot be obtained
329
+ */
330
+ async function extractClientCredentials(authConfig, appKey, envKey, _options = {}) {
331
+ if (authConfig.type === 'credentials') {
332
+ if (!authConfig.clientId || !authConfig.clientSecret) {
333
+ throw new Error('Client ID and Client Secret are required');
334
+ }
335
+ return {
336
+ clientId: authConfig.clientId,
337
+ clientSecret: authConfig.clientSecret
338
+ };
339
+ }
340
+
341
+ if (authConfig.type === 'bearer') {
342
+ if (authConfig.clientId && authConfig.clientSecret) {
343
+ return {
344
+ clientId: authConfig.clientId,
345
+ clientSecret: authConfig.clientSecret
346
+ };
347
+ }
348
+
349
+ // Try to load from secrets.local.yaml
350
+ const credentials = await loadClientCredentials(appKey);
351
+ if (credentials && credentials.clientId && credentials.clientSecret) {
352
+ // Store in authConfig so they're available for deployment step
353
+ authConfig.clientId = credentials.clientId;
354
+ authConfig.clientSecret = credentials.clientSecret;
355
+ return {
356
+ clientId: credentials.clientId,
357
+ clientSecret: credentials.clientSecret
358
+ };
359
+ }
360
+
361
+ // Construct clientId from controller, environment, and application key
362
+ // (not used, but shown in error message for reference)
363
+ throw new Error(`Client ID and Client Secret are required. Add credentials to ~/.aifabrix/secrets.local.yaml as '${appKey}-client-idKeyVault' and '${appKey}-client-secretKeyVault', or use credentials authentication.`);
364
+ }
365
+
366
+ throw new Error('Invalid authentication type');
367
+ }
368
+
369
+ module.exports = {
370
+ getDeviceToken,
371
+ getClientToken,
372
+ isTokenExpired,
373
+ refreshClientToken,
374
+ refreshDeviceToken,
375
+ loadClientCredentials,
376
+ getOrRefreshClientToken,
377
+ getOrRefreshDeviceToken,
378
+ getDeploymentAuth,
379
+ extractClientCredentials
380
+ };
381
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aifabrix/builder",
3
- "version": "2.1.7",
3
+ "version": "2.3.0",
4
4
  "description": "AI Fabrix Local Fabric & Deployment SDK",
5
5
  "main": "lib/index.js",
6
6
  "bin": {
@@ -32,7 +32,7 @@
32
32
  "container"
33
33
  ],
34
34
  "author": "eSystems Nordic Ltd",
35
- "license": "UNLICENSED",
35
+ "license": "MIT",
36
36
  "engines": {
37
37
  "node": ">=18.0.0"
38
38
  },