@aifabrix/builder 2.1.7 → 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.
@@ -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.2.0",
4
4
  "description": "AI Fabrix Local Fabric & Deployment SDK",
5
5
  "main": "lib/index.js",
6
6
  "bin": {
@@ -2,56 +2,193 @@
2
2
 
3
3
  Build, run, and deploy {{displayName}} using `@aifabrix/builder`.
4
4
 
5
- ## Install
5
+ ---
6
+
7
+ ## Quick Start
8
+
9
+ ### 1. Install
6
10
 
7
11
  ```bash
8
12
  npm install -g @aifabrix/builder
9
13
  ```
10
14
 
11
- ## Build
15
+ ### 2. First Time Setup
12
16
 
13
17
  ```bash
14
- aifabrix build {{appName}}
15
- ```
18
+ # Check your environment
19
+ aifabrix doctor
16
20
 
17
- Builds Docker image: `{{imageName}}:latest`
21
+ # Login to controller
22
+ aifabrix login --method device --environment dev
18
23
 
19
- ## Run Locally
24
+ # Register your application (gets you credentials automatically)
25
+ aifabrix app register {{appName}} --environment dev
26
+ ```
27
+
28
+ ### 3. Build & Run Locally
20
29
 
21
30
  ```bash
31
+ # Build the Docker image
32
+ aifabrix build {{appName}}
33
+
34
+ # Generate environment variables
35
+ aifabrix resolve {{appName}}
36
+
37
+ # Run locally
22
38
  aifabrix run {{appName}}
23
39
  ```
24
40
 
25
- **Access:** http://localhost:{{port}}
41
+ **Access your app:** http://localhost:{{port}}
26
42
 
27
- **Logs:**
43
+ **View logs:**
28
44
  ```bash
29
- docker logs {{appName}} -f
45
+ docker logs aifabrix-{{appName}} -f
30
46
  ```
31
47
 
32
48
  **Stop:**
33
49
  ```bash
34
- docker stop {{appName}}
50
+ docker stop aifabrix-{{appName}}
35
51
  ```
36
52
 
37
- ## Push to Azure Container Registry
53
+ ### 4. Deploy to Azure
38
54
 
39
55
  ```bash
40
- aifabrix push {{appName}} --registry {{registry}} --tag latest
56
+ # Build with version tag
57
+ aifabrix build {{appName}} --tag v1.0.0
58
+
59
+ # Push to registry
60
+ aifabrix push {{appName}} --registry {{registry}} --tag "v1.0.0,latest"
61
+
62
+ # Deploy to miso-controller
63
+ aifabrix deploy {{appName}} --controller https://controller.aifabrix.ai --environment dev
41
64
  ```
42
65
 
43
- **Note:** ACR push requires `az login` or ACR credentials in `variables.yaml`
66
+ ---
67
+
68
+ ## Using miso-client
69
+
70
+ > [miso-client](https://github.com/esystemsdev/aifabrix-miso-client)
71
+
72
+ After registering your app, you automatically get credentials in your secret file. Use miso-client for login, RBAC, audit logs, etc.
73
+
74
+ **Rotate credentials if needed:**
75
+ ```bash
76
+ aifabrix app rotate-secret {{appName}} --environment dev
77
+ ```
78
+
79
+ ---
80
+
81
+ ## Reference
82
+
83
+ ### Common Commands
84
+
85
+ ```bash
86
+ # Development
87
+ aifabrix build {{appName}} # Build app
88
+ aifabrix run {{appName}} # Run locally
89
+ aifabrix dockerfile {{appName}} --force # Generate Dockerfile
90
+ aifabrix resolve {{appName}} # Generate .env file
91
+
92
+ # Deployment
93
+ aifabrix json {{appName}} # Preview deployment JSON
94
+ aifabrix genkey {{appName}} # Generate deployment key
95
+ aifabrix push {{appName}} --registry {{registry}} # Push to ACR
96
+ aifabrix deploy {{appName}} --controller <url> # Deploy to Azure
97
+
98
+ # Management
99
+ aifabrix app register {{appName}} --environment dev
100
+ aifabrix app list --environment dev
101
+ aifabrix app rotate-secret {{appName}} --environment dev
102
+
103
+ # Utilities
104
+ aifabrix doctor # Check environment
105
+ aifabrix login --method device --environment dev # Login
106
+ aifabrix --help # Get help
107
+ ```
108
+
109
+ ### Build Options
110
+
111
+ ```bash
112
+ aifabrix build {{appName}} --tag v1.0.0 # Custom tag
113
+ aifabrix build {{appName}} --force-template # Force template regeneration
114
+ aifabrix build {{appName}} --language typescript # Override language detection
115
+ ```
116
+
117
+ ### Run Options
118
+
119
+ ```bash
120
+ aifabrix run {{appName}} --port {{port}} # Custom port
121
+ aifabrix run {{appName}} --debug # Debug output
122
+ ```
123
+
124
+ ### Push Options
125
+
126
+ ```bash
127
+ aifabrix push {{appName}} --registry {{registry}} --tag v1.0.0
128
+ aifabrix push {{appName}} --registry {{registry}} --tag "v1.0.0,latest,stable"
129
+ ```
130
+
131
+ ### Deploy Options
132
+
133
+ ```bash
134
+ aifabrix deploy {{appName}} --controller <url> --environment dev
135
+ aifabrix deploy {{appName}} --controller <url> --environment dev --no-poll
136
+ ```
137
+
138
+ ### Login Methods
139
+
140
+ ```bash
141
+ # Device code flow
142
+ aifabrix login --method device --environment dev
143
+
144
+ # Credentials (reads from secrets.local.yaml)
145
+ aifabrix login --method credentials --app {{appName}} --environment dev
146
+
147
+ # Explicit credentials
148
+ aifabrix login --method credentials --app {{appName}} --client-id $CLIENT_ID --client-secret $CLIENT_SECRET --environment dev
149
+ ```
150
+
151
+ ### Environment Variables
152
+
153
+ ```bash
154
+ export AIFABRIX_HOME=/custom/path
155
+ export AIFABRIX_SECRETS=/path/to/secrets.yaml
156
+ ```
157
+
158
+ ---
159
+
160
+ ## Troubleshooting
161
+
162
+ - **"Docker not running"** → Start Docker Desktop
163
+ - **"Not logged in"** → Run `aifabrix login` first
164
+ - **"Port already in use"** → Use `--port` flag or change `build.localPort` in `variables.yaml` (default: {{port}})
165
+ - **"Authentication failed"** → Run `aifabrix login` again
166
+ - **"Build fails"** → Check Docker is running and `variables.yaml` → `build.secrets` path is correct
167
+ - **"Can't connect"** → Verify infrastructure is running{{#if hasDatabase}} and PostgreSQL is accessible{{/if}}
168
+
169
+ **Regenerate files:**
170
+ ```bash
171
+ aifabrix resolve {{appName}} --force
172
+ aifabrix json {{appName}}
173
+ aifabrix genkey {{appName}}
174
+ ```
175
+
176
+ ---
44
177
 
45
178
  ## Prerequisites
46
179
 
47
180
  - `@aifabrix/builder` installed globally
48
181
  - Docker Desktop running
49
- - Infrastructure running (`aifabrix up`)
182
+ - Azure CLI installed (for push command)
183
+ - Authenticated with controller (for deploy command)
184
+ {{#unless hasAnyService}}
185
+ - Infrastructure running
186
+ {{/unless}}
50
187
  {{#if hasDatabase}}
51
- - PostgreSQL database
188
+ - PostgreSQL database (ensure infrastructure is running)
52
189
  {{/if}}
53
190
  {{#if hasRedis}}
54
- - Redis
191
+ - Redis (ensure infrastructure is running)
55
192
  {{/if}}
56
193
  {{#if hasStorage}}
57
194
  - File storage configured
@@ -60,11 +197,6 @@ aifabrix push {{appName}} --registry {{registry}} --tag latest
60
197
  - Authentication/RBAC configured
61
198
  {{/if}}
62
199
 
63
- ## Troubleshooting
64
-
65
- **Build fails:** Check Docker is running and `variables.yaml` → `build.secrets` path is correct
66
-
67
- **Can't connect:** Verify infrastructure is running (`aifabrix up`){{#if hasDatabase}} and PostgreSQL is accessible{{/if}}
68
-
69
- **Port in use:** Change `build.localPort` in `variables.yaml` (default: {{port}})
200
+ ---
70
201
 
202
+ **Application**: {{appName}} | **Port**: {{port}} | **Registry**: {{registry}} | **Image**: {{imageName}}:latest