@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.
- package/lib/app-deploy.js +73 -29
- package/lib/app-list.js +132 -0
- package/lib/app-readme.js +11 -4
- package/lib/app-register.js +435 -0
- package/lib/app-rotate-secret.js +164 -0
- package/lib/app-run.js +98 -84
- package/lib/app.js +13 -0
- package/lib/audit-logger.js +195 -15
- package/lib/build.js +155 -42
- package/lib/cli.js +104 -8
- package/lib/commands/app.js +8 -391
- package/lib/commands/login.js +130 -36
- package/lib/commands/secure.js +260 -0
- package/lib/config.js +315 -4
- package/lib/deployer.js +221 -183
- package/lib/infra.js +177 -112
- package/lib/push.js +34 -7
- package/lib/secrets.js +89 -23
- package/lib/templates.js +1 -1
- package/lib/utils/api-error-handler.js +465 -0
- package/lib/utils/api.js +165 -16
- package/lib/utils/auth-headers.js +84 -0
- package/lib/utils/build-copy.js +162 -0
- package/lib/utils/cli-utils.js +49 -3
- package/lib/utils/compose-generator.js +57 -16
- package/lib/utils/deployment-errors.js +90 -0
- package/lib/utils/deployment-validation.js +60 -0
- package/lib/utils/dev-config.js +83 -0
- package/lib/utils/docker-build.js +24 -0
- package/lib/utils/env-template.js +30 -10
- package/lib/utils/health-check.js +18 -1
- package/lib/utils/infra-containers.js +101 -0
- package/lib/utils/local-secrets.js +0 -2
- package/lib/utils/secrets-encryption.js +203 -0
- package/lib/utils/secrets-path.js +22 -3
- package/lib/utils/token-manager.js +381 -0
- package/package.json +2 -2
- package/templates/applications/README.md.hbs +155 -23
- package/templates/applications/miso-controller/Dockerfile +7 -119
- package/templates/infra/compose.yaml.hbs +93 -0
- package/templates/python/docker-compose.hbs +25 -17
- package/templates/typescript/docker-compose.hbs +25 -17
- 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.
|
|
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": "
|
|
35
|
+
"license": "MIT",
|
|
36
36
|
"engines": {
|
|
37
37
|
"node": ">=18.0.0"
|
|
38
38
|
},
|