@aifabrix/builder 2.1.6 → 2.2.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 +57 -37
- package/lib/cli.js +90 -8
- package/lib/commands/app.js +8 -391
- package/lib/commands/login.js +130 -36
- package/lib/config.js +257 -4
- package/lib/deployer.js +221 -183
- package/lib/infra.js +177 -112
- package/lib/secrets.js +85 -99
- 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 +144 -0
- package/lib/utils/cli-utils.js +21 -0
- package/lib/utils/compose-generator.js +43 -14
- 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/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-path.js +18 -21
- package/lib/utils/secrets-utils.js +206 -0
- package/lib/utils/token-manager.js +381 -0
- package/package.json +1 -1
- 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
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Fabrix Builder Secrets Utilities
|
|
3
|
+
*
|
|
4
|
+
* This module provides utility functions for loading and processing secrets.
|
|
5
|
+
* Helper functions for secrets.js module.
|
|
6
|
+
*
|
|
7
|
+
* @fileoverview Secrets utility functions 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
|
+
const os = require('os');
|
|
16
|
+
const logger = require('./logger');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Loads secrets from file with cascading lookup support
|
|
20
|
+
* First checks ~/.aifabrix/secrets.local.yaml, then build.secrets from variables.yaml
|
|
21
|
+
*
|
|
22
|
+
* @async
|
|
23
|
+
* @function loadSecretsFromFile
|
|
24
|
+
* @param {string} filePath - Path to secrets file
|
|
25
|
+
* @returns {Promise<Object>} Loaded secrets object or empty object if file doesn't exist
|
|
26
|
+
*/
|
|
27
|
+
async function loadSecretsFromFile(filePath) {
|
|
28
|
+
if (!fs.existsSync(filePath)) {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
34
|
+
const secrets = yaml.load(content);
|
|
35
|
+
|
|
36
|
+
if (!secrets || typeof secrets !== 'object') {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return secrets;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
logger.warn(`Warning: Could not read secrets file ${filePath}: ${error.message}`);
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Loads user secrets from ~/.aifabrix/secrets.local.yaml
|
|
49
|
+
* @function loadUserSecrets
|
|
50
|
+
* @returns {Object} Loaded secrets object or empty object
|
|
51
|
+
*/
|
|
52
|
+
function loadUserSecrets() {
|
|
53
|
+
const userSecretsPath = path.join(os.homedir(), '.aifabrix', 'secrets.local.yaml');
|
|
54
|
+
if (!fs.existsSync(userSecretsPath)) {
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const content = fs.readFileSync(userSecretsPath, 'utf8');
|
|
60
|
+
const secrets = yaml.load(content);
|
|
61
|
+
if (!secrets || typeof secrets !== 'object') {
|
|
62
|
+
throw new Error(`Invalid secrets file format: ${userSecretsPath}`);
|
|
63
|
+
}
|
|
64
|
+
return secrets;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error.message.includes('Invalid secrets file format')) {
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
logger.warn(`Warning: Could not read secrets file ${userSecretsPath}: ${error.message}`);
|
|
70
|
+
return {};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Loads build secrets from variables.yaml and merges with existing secrets
|
|
76
|
+
* @async
|
|
77
|
+
* @function loadBuildSecrets
|
|
78
|
+
* @param {Object} mergedSecrets - Existing secrets to merge with
|
|
79
|
+
* @param {string} appName - Application name
|
|
80
|
+
* @returns {Promise<Object>} Merged secrets object
|
|
81
|
+
*/
|
|
82
|
+
async function loadBuildSecrets(mergedSecrets, appName) {
|
|
83
|
+
const variablesPath = path.join(process.cwd(), 'builder', appName, 'variables.yaml');
|
|
84
|
+
if (!fs.existsSync(variablesPath)) {
|
|
85
|
+
return mergedSecrets;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const variablesContent = fs.readFileSync(variablesPath, 'utf8');
|
|
90
|
+
const variables = yaml.load(variablesContent);
|
|
91
|
+
|
|
92
|
+
if (variables?.build?.secrets) {
|
|
93
|
+
const buildSecretsPath = path.resolve(
|
|
94
|
+
path.dirname(variablesPath),
|
|
95
|
+
variables.build.secrets
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const buildSecrets = await loadSecretsFromFile(buildSecretsPath);
|
|
99
|
+
|
|
100
|
+
// Merge: user's file takes priority, but use build.secrets for missing/empty values
|
|
101
|
+
for (const [key, value] of Object.entries(buildSecrets)) {
|
|
102
|
+
if (!(key in mergedSecrets) || !mergedSecrets[key] || mergedSecrets[key] === '') {
|
|
103
|
+
mergedSecrets[key] = value;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} catch (error) {
|
|
108
|
+
logger.warn(`Warning: Could not load build.secrets from variables.yaml: ${error.message}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return mergedSecrets;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Loads default secrets from ~/.aifabrix/secrets.yaml
|
|
116
|
+
* @function loadDefaultSecrets
|
|
117
|
+
* @returns {Object} Loaded secrets object or empty object
|
|
118
|
+
*/
|
|
119
|
+
function loadDefaultSecrets() {
|
|
120
|
+
const defaultPath = path.join(os.homedir(), '.aifabrix', 'secrets.yaml');
|
|
121
|
+
if (!fs.existsSync(defaultPath)) {
|
|
122
|
+
return {};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const content = fs.readFileSync(defaultPath, 'utf8');
|
|
127
|
+
const secrets = yaml.load(content);
|
|
128
|
+
if (!secrets || typeof secrets !== 'object') {
|
|
129
|
+
throw new Error(`Invalid secrets file format: ${defaultPath}`);
|
|
130
|
+
}
|
|
131
|
+
return secrets;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (error.message.includes('Invalid secrets file format')) {
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
logger.warn(`Warning: Could not read secrets file ${defaultPath}: ${error.message}`);
|
|
137
|
+
return {};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Builds a map of hostname to service name from environment config
|
|
143
|
+
* @function buildHostnameToServiceMap
|
|
144
|
+
* @param {Object} dockerHosts - Docker environment hosts configuration
|
|
145
|
+
* @returns {Object} Map of hostname to service name
|
|
146
|
+
*/
|
|
147
|
+
function buildHostnameToServiceMap(dockerHosts) {
|
|
148
|
+
const hostnameToService = {};
|
|
149
|
+
for (const [key, hostname] of Object.entries(dockerHosts)) {
|
|
150
|
+
if (key.endsWith('_HOST')) {
|
|
151
|
+
// Use hostname directly as service name (e.g., 'keycloak', 'miso-controller')
|
|
152
|
+
hostnameToService[hostname] = hostname;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return hostnameToService;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Resolves port for a single URL by looking up service's variables.yaml
|
|
160
|
+
* @function resolveUrlPort
|
|
161
|
+
* @param {string} protocol - URL protocol (http:// or https://)
|
|
162
|
+
* @param {string} hostname - Service hostname
|
|
163
|
+
* @param {string} port - Current port
|
|
164
|
+
* @param {string} urlPath - URL path and query string
|
|
165
|
+
* @param {Object} hostnameToService - Map of hostname to service name
|
|
166
|
+
* @returns {string} URL with resolved port
|
|
167
|
+
*/
|
|
168
|
+
function resolveUrlPort(protocol, hostname, port, urlPath, hostnameToService) {
|
|
169
|
+
const serviceName = hostnameToService[hostname];
|
|
170
|
+
if (!serviceName) {
|
|
171
|
+
// Not a service hostname, keep original
|
|
172
|
+
return `${protocol}${hostname}:${port}${urlPath}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Try to load service's variables.yaml
|
|
176
|
+
const serviceVariablesPath = path.join(process.cwd(), 'builder', serviceName, 'variables.yaml');
|
|
177
|
+
if (!fs.existsSync(serviceVariablesPath)) {
|
|
178
|
+
// Service variables.yaml not found, keep original port
|
|
179
|
+
return `${protocol}${hostname}:${port}${urlPath}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const variablesContent = fs.readFileSync(serviceVariablesPath, 'utf8');
|
|
184
|
+
const variables = yaml.load(variablesContent);
|
|
185
|
+
|
|
186
|
+
// Get containerPort or fall back to port
|
|
187
|
+
const containerPort = variables?.build?.containerPort || variables?.port || port;
|
|
188
|
+
|
|
189
|
+
// Replace port in URL
|
|
190
|
+
return `${protocol}${hostname}:${containerPort}${urlPath}`;
|
|
191
|
+
} catch (error) {
|
|
192
|
+
// Error loading variables.yaml, keep original port
|
|
193
|
+
logger.warn(`Warning: Could not load variables.yaml for service ${serviceName}: ${error.message}`);
|
|
194
|
+
return `${protocol}${hostname}:${port}${urlPath}`;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = {
|
|
199
|
+
loadSecretsFromFile,
|
|
200
|
+
loadUserSecrets,
|
|
201
|
+
loadBuildSecrets,
|
|
202
|
+
loadDefaultSecrets,
|
|
203
|
+
buildHostnameToServiceMap,
|
|
204
|
+
resolveUrlPort
|
|
205
|
+
};
|
|
206
|
+
|
|
@@ -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
|
+
|