@aifabrix/builder 2.55.0 → 2.55.2

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 (70) hide show
  1. package/lib/agent-kit/host-approve.js +48 -6
  2. package/lib/agent-kit/init.js +2 -2
  3. package/lib/agent-kit/remote-prompts.js +46 -13
  4. package/lib/agent-kit/run.js +1 -1
  5. package/lib/agent-kit/start.js +310 -0
  6. package/lib/app/deploy.js +7 -4
  7. package/lib/app/offer-create-from-template.js +184 -0
  8. package/lib/cli/setup-app.js +8 -1
  9. package/lib/cli/setup-auth.js +11 -2
  10. package/lib/commands/agent-kit-help.js +74 -4
  11. package/lib/commands/agent-kit.js +83 -12
  12. package/lib/commands/auth-status-user.js +99 -0
  13. package/lib/commands/auth-status.js +14 -4
  14. package/lib/commands/datasource-load-export-cli.js +7 -2
  15. package/lib/commands/datasource-test-trust-cli.js +5 -1
  16. package/lib/commands/governance-command-action.js +5 -4
  17. package/lib/commands/identity-membership-role-cache.js +6 -1
  18. package/lib/commands/lifecycle-command-action.js +16 -15
  19. package/lib/commands/login-credentials.js +8 -3
  20. package/lib/commands/login-device.js +14 -5
  21. package/lib/commands/login.js +14 -1
  22. package/lib/commands/logout.js +50 -15
  23. package/lib/commands/role-assistant.js +24 -12
  24. package/lib/commands/verify-governance-command-action.js +9 -7
  25. package/lib/commands/verify-operations-command-action.js +9 -8
  26. package/lib/commands/verify-trust-command-action.js +6 -5
  27. package/lib/external-system/download.js +1 -2
  28. package/lib/generator/helpers.js +12 -1
  29. package/lib/integration-definition/apply-static-enterprise.js +5 -2
  30. package/lib/role-assistant/knowledge-sync-settings-push.js +172 -0
  31. package/lib/role-assistant/knowledge-sync.js +9 -126
  32. package/lib/role-assistant/test-cases-evaluate.js +12 -1
  33. package/lib/role-assistant/test-cases-evidence.js +44 -5
  34. package/lib/role-assistant/test-cases-lifecycle.js +9 -3
  35. package/lib/role-assistant/test-runner.js +3 -0
  36. package/lib/schema/infra-parameter.schema.json +51 -3
  37. package/lib/schema/infra.parameter.yaml +476 -40
  38. package/lib/schema/type/document-storage.json +1 -1
  39. package/lib/utils/api-required-permissions.js +271 -0
  40. package/lib/utils/api.js +16 -13
  41. package/lib/utils/cli-json-exit.js +41 -0
  42. package/lib/utils/cli-utils.js +23 -5
  43. package/lib/utils/controller-url.js +78 -4
  44. package/lib/utils/deployment-errors.js +15 -0
  45. package/lib/utils/deployment-validation-helpers.js +4 -1
  46. package/lib/utils/device-code.js +31 -10
  47. package/lib/utils/error-formatters/error-parser.js +16 -1
  48. package/lib/utils/error-formatters/permission-errors.js +19 -5
  49. package/lib/utils/keycloak-realm-ready.js +5 -2
  50. package/lib/utils/logger.js +1 -0
  51. package/lib/utils/paths.js +26 -5
  52. package/lib/utils/secrets-helpers.js +2 -2
  53. package/lib/utils/write-stream-and-wait.js +63 -0
  54. package/package.json +20 -6
  55. package/templates/agent-kit/agent-kit.yaml +1 -1
  56. package/templates/agent-kit/instructions/AGENTKIT.md +25 -4
  57. package/templates/agent-kit/instructions/root.AGENTS.md +1 -1
  58. package/templates/agent-kit/skills/aifabrix-connected-system/SKILL.md +2 -1
  59. package/templates/agent-kit/skills/aifabrix-connected-system/references/delivery-gates.md +1 -1
  60. package/templates/agent-kit/skills/aifabrix-plan/SKILL.md +2 -1
  61. package/templates/agent-kit/skills/aifabrix-prove/SKILL.md +1 -0
  62. package/templates/agent-kit/skills/aifabrix-role-assistant/SKILL.md +1 -0
  63. package/templates/agent-kit/skills/shared/hosts.md +13 -26
  64. package/templates/agent-kit/skills/shared/login.md +16 -0
  65. package/templates/agent-kit/workspace/README.md +1 -1
  66. package/templates/applications/dataplane/application.yaml +1 -2
  67. package/templates/applications/dataplane/env.template +5 -5
  68. package/templates/applications/miso-controller/application.yaml +21 -2
  69. package/templates/applications/miso-controller/env.template +89 -17
  70. package/templates/applications/miso-controller/rbac.yaml +5 -0
@@ -282,7 +282,7 @@
282
282
  },
283
283
  "fieldHints": {
284
284
  "type": "object",
285
- "description": "Optional per-field extraction hints for governed FK review (plan 441.1).",
285
+ "description": "Optional per-field guidance for AI metadata extraction and governed FK review.",
286
286
  "additionalProperties": {
287
287
  "type": "string",
288
288
  "minLength": 1,
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Resolve required RBAC scopes for online API calls.
3
+ *
4
+ * Catalog is built from `@requiresPermission` plus the HTTP path in `lib/api/*.api.js`.
5
+ * Used when a 403 body does not list missing/required permissions.
6
+ *
7
+ * @fileoverview Fallback required-permission lookup for 403 errors
8
+ * @author AI Fabrix Team
9
+ * @version 2.0.0
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ const fsRealSync = require('../internal/fs-real-sync');
15
+ const path = require('path');
16
+
17
+ const SCOPE_IN_TEXT_RE = /\b[a-z][a-z0-9_-]*:[a-z][a-z0-9_-]*(?::[a-z][a-z0-9_-]*)*\b/gi;
18
+ const SKIP_SCOPE_SCHEMES = new Set(['http', 'https', 'mailto', 'ws', 'wss']);
19
+
20
+ /** @type {Array<{ method: string, regex: RegExp, service: string, permissions: string[] }>|null} */
21
+ let cachedCatalog = null;
22
+
23
+ /**
24
+ * CLI command name (handleCommandError second arg) → required permission fallback.
25
+ * URL catalog is preferred; this is used when the 403 has no request URL.
26
+ */
27
+ const COMMAND_REQUIRED_PERMISSIONS = Object.freeze({
28
+ deploy: { service: 'Controller', permissions: ['applications:deploy'] },
29
+ 'env deploy': { service: 'Controller', permissions: ['controller:deploy'] },
30
+ upload: { service: 'Dataplane', permissions: ['external-system:publish'] },
31
+ download: { service: 'Dataplane', permissions: ['external-system:read'] },
32
+ delete: { service: 'Dataplane', permissions: ['external-system:delete'] },
33
+ wizard: { service: 'Dataplane', permissions: ['external-system:create'] },
34
+ 'credential list': { service: 'Dataplane', permissions: ['credential:read'] },
35
+ 'deployment list': { service: 'Controller', permissions: ['deployments:read'] },
36
+ 'app register': { service: 'Controller', permissions: ['environments-applications:create'] },
37
+ 'app list': { service: 'Controller', permissions: ['environments-applications:read'] }
38
+ });
39
+
40
+ /**
41
+ * @param {string} spec
42
+ * @returns {boolean}
43
+ */
44
+ function looksLikeRbacSpec(spec) {
45
+ if (!spec || typeof spec !== 'string') return false;
46
+ const trimmed = spec.trim();
47
+ if (/^(none|public)\b/i.test(trimmed) && !trimmed.includes(':')) return false;
48
+ if (/authenticated\s*\(/i.test(trimmed)) return false;
49
+ if (/client credentials/i.test(trimmed) && !/[a-z0-9_-]+:[a-z0-9_-]+/i.test(trimmed)) return false;
50
+ return /[a-z0-9_-]+:[a-z0-9_-]+/i.test(trimmed);
51
+ }
52
+
53
+ /**
54
+ * @param {string} spec
55
+ * @returns {string[]}
56
+ */
57
+ function permissionListFromSpec(spec) {
58
+ const main = String(spec).split('(')[0].trim();
59
+ if (!main) return [];
60
+ if (/\bor\b/i.test(main)) return [main];
61
+ return main.split(/\s*\+\s*/).map((part) => part.trim()).filter(Boolean);
62
+ }
63
+
64
+ /**
65
+ * @param {string} template
66
+ * @returns {RegExp}
67
+ */
68
+ function pathTemplateToRegex(template) {
69
+ const parts = String(template).split('/');
70
+ const escaped = parts.map((seg) => {
71
+ if (/^\{[^}]+\}$/.test(seg)) return '[^/]+';
72
+ return seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
73
+ }).join('/');
74
+ return new RegExp(`^${escaped}$`);
75
+ }
76
+
77
+ /**
78
+ * @param {string} block
79
+ * @returns {{ method: string, path: string, service: string, permissions: string[] }|null}
80
+ */
81
+ function parsePermissionCommentBlock(block) {
82
+ const text = String(block).replace(/^\s*\*\s?/gm, '');
83
+ const methodPath = text.match(/\b(GET|POST|PUT|PATCH|DELETE)\s+(\/api\/[^\s*]+)/);
84
+ const perm = text.match(/@requiresPermission\s+\{([^}]+)\}\s+([^\n*]+)/);
85
+ if (!methodPath || !perm) return null;
86
+ const spec = String(perm[2] || '').trim();
87
+ if (!looksLikeRbacSpec(spec)) return null;
88
+ const permissions = permissionListFromSpec(spec);
89
+ if (permissions.length === 0) return null;
90
+ return {
91
+ method: methodPath[1].toUpperCase(),
92
+ path: methodPath[2].replace(/\/+$/, ''),
93
+ service: String(perm[1] || '').trim(),
94
+ permissions
95
+ };
96
+ }
97
+
98
+ /**
99
+ * @param {string} source
100
+ * @returns {Array<{ method: string, regex: RegExp, service: string, permissions: string[], pathLength: number }>}
101
+ */
102
+ function parsePermissionEntriesFromSource(source) {
103
+ const entries = [];
104
+ const blocks = String(source).match(/\/\*\*[\s\S]*?\*\//g) || [];
105
+ for (const block of blocks) {
106
+ const parsed = parsePermissionCommentBlock(block);
107
+ if (!parsed) continue;
108
+ entries.push({
109
+ method: parsed.method,
110
+ regex: pathTemplateToRegex(parsed.path),
111
+ service: parsed.service,
112
+ permissions: parsed.permissions,
113
+ pathLength: parsed.path.length
114
+ });
115
+ }
116
+ return entries;
117
+ }
118
+
119
+ /**
120
+ * @returns {Array<{ method: string, regex: RegExp, service: string, permissions: string[], pathLength: number }>}
121
+ */
122
+ function loadApiPermissionCatalog() {
123
+ if (cachedCatalog) return cachedCatalog;
124
+ const apiDir = path.join(__dirname, '..', 'api');
125
+ const entries = [];
126
+ let names = [];
127
+ try {
128
+ names = fsRealSync.readdirSync(apiDir);
129
+ } catch {
130
+ cachedCatalog = [];
131
+ return cachedCatalog;
132
+ }
133
+ for (const name of names) {
134
+ if (!name.endsWith('.api.js')) continue;
135
+ const source = fsRealSync.readFileSync(path.join(apiDir, name), 'utf8');
136
+ entries.push(...parsePermissionEntriesFromSource(source));
137
+ }
138
+ entries.sort((a, b) => b.pathLength - a.pathLength);
139
+ cachedCatalog = entries;
140
+ return cachedCatalog;
141
+ }
142
+
143
+ /**
144
+ * @param {string} [url]
145
+ * @returns {string}
146
+ */
147
+ function apiPathFromUrl(url) {
148
+ const raw = String(url || '');
149
+ try {
150
+ const parsed = new URL(raw);
151
+ const idx = parsed.pathname.indexOf('/api/');
152
+ return idx >= 0 ? parsed.pathname.slice(idx).replace(/\/+$/, '') : parsed.pathname;
153
+ } catch {
154
+ const idx = raw.indexOf('/api/');
155
+ const pathOnly = idx >= 0 ? raw.slice(idx).split('?')[0] : raw.split('?')[0];
156
+ return pathOnly.replace(/\/+$/, '');
157
+ }
158
+ }
159
+
160
+ /**
161
+ * @param {string} [method]
162
+ * @param {string} [url]
163
+ * @returns {{ service: string, permissions: string[] }|null}
164
+ */
165
+ function lookupRequiredPermission(method, url) {
166
+ const verb = String(method || '').toUpperCase();
167
+ const apiPath = apiPathFromUrl(url);
168
+ if (!verb || !apiPath.startsWith('/api/')) return null;
169
+ const catalog = loadApiPermissionCatalog();
170
+ for (const entry of catalog) {
171
+ if (entry.method === verb && entry.regex.test(apiPath)) {
172
+ return { service: entry.service, permissions: entry.permissions };
173
+ }
174
+ }
175
+ return null;
176
+ }
177
+
178
+ /**
179
+ * @param {string} [command]
180
+ * @returns {{ service: string, permissions: string[] }|null}
181
+ */
182
+ function lookupCommandRequiredPermission(command) {
183
+ if (!command || typeof command !== 'string') return null;
184
+ return COMMAND_REQUIRED_PERMISSIONS[command] || null;
185
+ }
186
+
187
+ /**
188
+ * @param {string} text
189
+ * @returns {string[]}
190
+ */
191
+ function extractPermissionTokensFromText(text) {
192
+ if (!text || typeof text !== 'string') return [];
193
+ const found = text.match(SCOPE_IN_TEXT_RE) || [];
194
+ const unique = [];
195
+ for (const token of found) {
196
+ const scheme = token.split(':')[0].toLowerCase();
197
+ if (SKIP_SCOPE_SCHEMES.has(scheme)) continue;
198
+ if (!unique.includes(token)) unique.push(token);
199
+ }
200
+ return unique;
201
+ }
202
+
203
+ /**
204
+ * @param {Object} data
205
+ * @returns {boolean}
206
+ */
207
+ function hasBodyPermissionLists(data) {
208
+ if (!data || typeof data !== 'object') return false;
209
+ const lists = [
210
+ data.missingPermissions,
211
+ data.requiredPermissions,
212
+ data.permissions,
213
+ data.missing && data.missing.permissions,
214
+ data.required && data.required.permissions,
215
+ data.data && data.data.missing && data.data.missing.permissions,
216
+ data.data && data.data.required && data.data.required.permissions
217
+ ];
218
+ return lists.some((list) => Array.isArray(list) && list.length > 0);
219
+ }
220
+
221
+ /**
222
+ * @param {*} errorData
223
+ * @returns {Object}
224
+ */
225
+ function asErrorObject(errorData) {
226
+ if (errorData && typeof errorData === 'object' && !Array.isArray(errorData)) {
227
+ return { ...errorData };
228
+ }
229
+ const text = errorData === undefined || errorData === null ? 'Permission denied' : String(errorData);
230
+ return { detail: text, message: text };
231
+ }
232
+
233
+ /**
234
+ * When a 403 body has no permission list, fill requiredPermissions from the request catalog.
235
+ *
236
+ * @param {*} errorData
237
+ * @param {{ status?: number, method?: string, url?: string }} [ctx]
238
+ * @returns {*}
239
+ */
240
+ function enrichForbiddenErrorData(errorData, ctx = {}) {
241
+ if (ctx.status !== 403) return errorData;
242
+ const data = asErrorObject(errorData);
243
+ if (ctx.method && !data.method) data.method = ctx.method;
244
+ if (ctx.url && !data.instance && !data.url) data.url = ctx.url;
245
+ if (hasBodyPermissionLists(data)) {
246
+ return data;
247
+ }
248
+ const fromText = extractPermissionTokensFromText(
249
+ [data.detail, data.message, data.error, data.title, data.errorDescription].filter(Boolean).join(' ')
250
+ );
251
+ if (fromText.length > 0) {
252
+ data.requiredPermissions = fromText;
253
+ return data;
254
+ }
255
+ const hit = lookupRequiredPermission(ctx.method, ctx.url);
256
+ if (!hit) return data;
257
+ data.cliPermissionService = hit.service;
258
+ data.requiredPermissions = hit.permissions;
259
+ return data;
260
+ }
261
+
262
+ module.exports = {
263
+ lookupRequiredPermission,
264
+ lookupCommandRequiredPermission,
265
+ enrichForbiddenErrorData,
266
+ extractPermissionTokensFromText,
267
+ parsePermissionEntriesFromSource,
268
+ parsePermissionCommentBlock,
269
+ apiPathFromUrl,
270
+ COMMAND_REQUIRED_PERMISSIONS
271
+ };
package/lib/utils/api.js CHANGED
@@ -98,7 +98,12 @@ function parseErrorText(errorText, status, statusText) {
98
98
  async function handleErrorResponse(response, url, options, duration) {
99
99
  const errorText = await response.text();
100
100
  const errorData = parseErrorText(errorText, response.status, response.statusText);
101
- const parsedError = parseErrorResponse(errorData, response.status, false);
101
+ const { enrichForbiddenErrorData } = require('./api-required-permissions');
102
+ const parsedError = parseErrorResponse(enrichForbiddenErrorData(errorData, {
103
+ status: response.status,
104
+ method: options && options.method,
105
+ url
106
+ }), response.status, false);
102
107
 
103
108
  await logApiPerformance({
104
109
  url,
@@ -406,21 +411,14 @@ function setAuthHeader(headers, token, authType) {
406
411
  * @param {string} [tokenOrAuthConfig.controller] - Controller URL for token refresh (if object)
407
412
  * @returns {Promise<Object>} Response object
408
413
  */
409
- /**
410
- * Retry once after device-token refresh on 401 (Bearer only; never for client-token/preserveBearer).
411
- * @param {Object} response
412
- * @param {string} url
413
- * @param {Object} options
414
- * @param {Object} headers
415
- * @param {string|null} authControllerUrl
416
- * @returns {Promise<Object>}
417
- */
414
+ /** Retry once after device-token refresh on 401 (Bearer only). */
418
415
  async function retryApiCallAfterDeviceTokenRefresh(
419
416
  response,
420
417
  url,
421
418
  options,
422
419
  headers,
423
- authControllerUrl
420
+ authControllerUrl,
421
+ authConfig
424
422
  ) {
425
423
  try {
426
424
  const { forceRefreshDeviceToken } = require('./token-manager');
@@ -429,6 +427,11 @@ async function retryApiCallAfterDeviceTokenRefresh(
429
427
  );
430
428
  if (refreshedToken?.token) {
431
429
  headers.Authorization = `Bearer ${refreshedToken.token}`;
430
+ // Keep long-running commands current instead of reusing the expired bearer.
431
+ if (authConfig && typeof authConfig === 'object') {
432
+ authConfig.token = refreshedToken.token;
433
+ authConfig.controller = refreshedToken.controller || authControllerUrl;
434
+ }
432
435
  return await makeApiCall(url, { ...options, headers });
433
436
  }
434
437
  const authError =
@@ -471,7 +474,8 @@ async function authenticatedApiCall(url, options = {}, tokenOrAuthConfig) {
471
474
  url,
472
475
  options,
473
476
  headers,
474
- authControllerUrl
477
+ authControllerUrl,
478
+ isStringToken ? null : tokenOrAuthConfig
475
479
  );
476
480
  }
477
481
 
@@ -492,4 +496,3 @@ module.exports = {
492
496
  displayDeviceCodeInfo,
493
497
  refreshDeviceToken
494
498
  };
495
-
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Truncation-safe JSON stdout write + exit for CLI commands.
3
+ *
4
+ * @fileoverview Shared helper for `--json` command output followed by a
5
+ * non-zero process exit.
6
+ * @author AI Fabrix Team
7
+ * @version 1.0.0
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const logger = require('./logger');
13
+ const { writeStreamAndWait } = require('./write-stream-and-wait');
14
+
15
+ /**
16
+ * Write a JSON envelope to stdout and exit with the given code, without
17
+ * truncating the output.
18
+ *
19
+ * Writes to a piped (non-TTY) stdout are asynchronous on Linux; calling
20
+ * `process.exit()` immediately after `logger.log()`/`console.log()` can
21
+ * kill the process before the OS finishes flushing a large write, silently
22
+ * truncating the JSON. Waiting for the `stdout.write` callback before
23
+ * exiting avoids that. Applies `logger.sanitizeValue` first, when available,
24
+ * so token/secret masking still happens (bypassing `logger.log()` skips it
25
+ * otherwise) — falls back to an identity function for test doubles that
26
+ * mock `logger` without it.
27
+ *
28
+ * @param {unknown} payload - Value to serialize as the JSON envelope.
29
+ * @param {number} exitCode - Exit code; 0 resolves without exiting.
30
+ * @returns {Promise<void>}
31
+ */
32
+ async function writeJsonEnvelopeAndExit(payload, exitCode) {
33
+ const sanitize = typeof logger.sanitizeValue === 'function' ? logger.sanitizeValue : v => v;
34
+ const json = `${JSON.stringify(sanitize(payload), null, 2)}\n`;
35
+ await writeStreamAndWait(process.stdout, json);
36
+ if (exitCode !== 0) {
37
+ process.exit(exitCode);
38
+ }
39
+ }
40
+
41
+ module.exports = { writeJsonEnvelopeAndExit };
@@ -83,9 +83,11 @@ function isPortConflictError(errorMsg) {
83
83
  * @returns {boolean} True if permission denied error
84
84
  */
85
85
  function isPermissionDeniedError(errorMsg) {
86
- return (errorMsg.includes('permission denied') || errorMsg.includes('EACCES') || errorMsg.includes('Permission denied')) &&
87
- !errorMsg.includes('permissions/') &&
88
- !errorMsg.includes('Field "permissions');
86
+ const text = String(errorMsg || '');
87
+ const lower = text.toLowerCase();
88
+ return (lower.includes('permission denied') || lower.includes('eacces')) &&
89
+ !text.includes('permissions/') &&
90
+ !text.includes('Field "permissions');
89
91
  }
90
92
 
91
93
  /**
@@ -287,7 +289,7 @@ function formatApiPermissionError(errorMsg) {
287
289
  if (isLocalFilesystemPermissionDeniedError(errorMsg)) return null;
288
290
  return [
289
291
  ` ${errorMsg}`,
290
- ' Ensure your token has the required permission (e.g. external-system:delete for delete).'
292
+ ' Assign the required permission on the Controller or Dataplane for this environment (roles and groups).'
291
293
  ];
292
294
  }
293
295
 
@@ -418,11 +420,27 @@ function isAuthenticationError(error) {
418
420
  * @param {Error} error - The error that occurred
419
421
  * @param {string} command - Command that failed
420
422
  */
423
+ function appendCommandPermissionHint(errorMessages, command) {
424
+ const blob = Array.isArray(errorMessages) ? errorMessages.join('\n') : '';
425
+ if (!isPermissionDeniedError(blob)) return errorMessages;
426
+ if (/Required permissions:|Missing permissions:|Required permission \(/.test(blob)) {
427
+ return errorMessages;
428
+ }
429
+ const { lookupCommandRequiredPermission } = require('./api-required-permissions');
430
+ const hit = lookupCommandRequiredPermission(command);
431
+ if (!hit) return errorMessages;
432
+ return [
433
+ ...errorMessages,
434
+ ` Required permission (${hit.service}): ${hit.permissions.join(', ')}`,
435
+ ` Assign it on the ${hit.service} for this environment (roles and groups).`
436
+ ];
437
+ }
438
+
421
439
  function handleCommandError(error, command) {
422
440
  if (error && error.compactGateAbortLogged === true) {
423
441
  return;
424
442
  }
425
- const errorMessages = formatError(error);
443
+ const errorMessages = appendCommandPermissionHint(formatError(error), command);
426
444
  const skipDoctor = isMissingSecretsErrorMessage(error && error.message);
427
445
  logError(command, errorMessages, { skipDoctor });
428
446
  if (error.wizardResumeMessage) {
@@ -14,6 +14,10 @@ const devConfig = require('./dev-config');
14
14
  const config = require('../core/config');
15
15
  const { getDeviceToken, isTokenExpired } = require('./token-manager');
16
16
 
17
+ /** Default Front Door / Traefik mount for Miso Controller on public hosts. */
18
+ const DEFAULT_CONTROLLER_VDIR = '/miso';
19
+ const CONTROLLER_HEALTH_TIMEOUT_MS = 2500;
20
+
17
21
  /**
18
22
  * Calculate default controller URL based on developer ID
19
23
  * Uses getDevPorts to get the app port which is adjusted by developer ID
@@ -132,14 +136,58 @@ async function isPlatformAuthValidForController(controllerUrl) {
132
136
  }
133
137
 
134
138
  /**
135
- * Best-effort GET /health on the controller (no auth). Used to avoid token refresh HTTP
136
- * during setup before Miso Controller is running.
139
+ * True for local controller ports (no Front Door /miso vdir).
140
+ * @param {string} hostname
141
+ * @returns {boolean}
142
+ */
143
+ function isLoopbackControllerHost(hostname) {
144
+ const host = String(hostname || '').toLowerCase();
145
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1';
146
+ }
147
+
148
+ /**
149
+ * @param {string} pathname
150
+ * @returns {boolean}
151
+ */
152
+ function isMisoVdirPath(pathname) {
153
+ return pathname === DEFAULT_CONTROLLER_VDIR
154
+ || pathname.startsWith(`${DEFAULT_CONTROLLER_VDIR}/`);
155
+ }
156
+
157
+ /**
158
+ * Strip extra path segments. Public hosts always use origin + /miso.
159
+ * Loopback keeps origin unless the path is already /miso.
137
160
  *
161
+ * @param {string} url
162
+ * @returns {string}
163
+ */
164
+ function canonicalizeControllerLoginUrl(url) {
165
+ const base = normalizeUrl(url);
166
+ if (!base) {
167
+ return url;
168
+ }
169
+ let parsed;
170
+ try {
171
+ parsed = new URL(base);
172
+ } catch {
173
+ return base;
174
+ }
175
+ const origin = `${parsed.protocol}//${parsed.host}`;
176
+ const pathname = (parsed.pathname || '/').replace(/\/+$/, '') || '/';
177
+ if (isLoopbackControllerHost(parsed.hostname)) {
178
+ return isMisoVdirPath(pathname) ? `${origin}${DEFAULT_CONTROLLER_VDIR}` : origin;
179
+ }
180
+ return `${origin}${DEFAULT_CONTROLLER_VDIR}`;
181
+ }
182
+
183
+ /**
184
+ * Best-effort GET /health on the controller (no auth).
138
185
  * @async
139
186
  * @param {string} controllerUrl
187
+ * @param {number} timeoutMs
140
188
  * @returns {Promise<boolean>}
141
189
  */
142
- async function isControllerHealthReachable(controllerUrl) {
190
+ async function probeControllerHealth(controllerUrl, timeoutMs) {
143
191
  const base = normalizeUrl(controllerUrl);
144
192
  if (!base) {
145
193
  return false;
@@ -147,7 +195,7 @@ async function isControllerHealthReachable(controllerUrl) {
147
195
  try {
148
196
  const { fetchWithOptionalDevPlatformTls } = require('./dev-platform-tls');
149
197
  const res = await fetchWithOptionalDevPlatformTls(`${base}/health`, {
150
- signal: AbortSignal.timeout(2500)
198
+ signal: AbortSignal.timeout(timeoutMs)
151
199
  });
152
200
  return res.ok;
153
201
  } catch {
@@ -155,6 +203,29 @@ async function isControllerHealthReachable(controllerUrl) {
155
203
  }
156
204
  }
157
205
 
206
+ /**
207
+ * Best-effort GET /health on the controller (no auth). Used to avoid token refresh HTTP
208
+ * during setup before Miso Controller is running.
209
+ *
210
+ * @async
211
+ * @param {string} controllerUrl
212
+ * @returns {Promise<boolean>}
213
+ */
214
+ async function isControllerHealthReachable(controllerUrl) {
215
+ return probeControllerHealth(controllerUrl, CONTROLLER_HEALTH_TIMEOUT_MS);
216
+ }
217
+
218
+ /**
219
+ * Resolve a controller base URL for login: public hosts always origin + /miso.
220
+ *
221
+ * @async
222
+ * @param {string} controllerUrl
223
+ * @returns {Promise<string>} Canonical controller URL
224
+ */
225
+ async function discoverControllerBaseUrl(controllerUrl) {
226
+ return canonicalizeControllerLoginUrl(controllerUrl);
227
+ }
228
+
158
229
  /**
159
230
  * Get controller URL from logged-in user's device tokens.
160
231
  * Prefers the entry under {@link config.controller} when it matches a `device` key; otherwise a
@@ -237,11 +308,14 @@ async function resolveControllerUrl() {
237
308
  }
238
309
 
239
310
  module.exports = {
311
+ DEFAULT_CONTROLLER_VDIR,
240
312
  getDefaultControllerUrl,
241
313
  hasStoredDeviceTokenForController,
242
314
  isDeviceTokenUsableForController,
243
315
  isPlatformAuthValidForController,
244
316
  isControllerHealthReachable,
317
+ canonicalizeControllerLoginUrl,
318
+ discoverControllerBaseUrl,
245
319
  getControllerUrlFromLoggedInUser,
246
320
  getControllerFromConfig,
247
321
  resolveControllerUrl
@@ -193,6 +193,17 @@ function createFormattedError(parsedError, safeError, originalError) {
193
193
  return formattedError;
194
194
  }
195
195
 
196
+ function createPreservedFormattedError(error, safeError) {
197
+ if (!error || typeof error.formatted !== 'string' || !error.formatted.trim()) {
198
+ return null;
199
+ }
200
+ return createFormattedError({
201
+ message: error.message || error.formatted,
202
+ formatted: error.formatted,
203
+ data: error.data
204
+ }, safeError, error);
205
+ }
206
+
196
207
  /**
197
208
  * Unified error handler for deployment errors
198
209
  * Handles audit logging, error formatting, and user-friendly messages
@@ -227,6 +238,10 @@ async function handleDeploymentErrors(error, appName, url, alreadyLogged = false
227
238
  }
228
239
 
229
240
  const safeError = handleDeploymentError(error);
241
+ const preserved = createPreservedFormattedError(error, safeError);
242
+ if (preserved) {
243
+ throw preserved;
244
+ }
230
245
  const errorResponse = extractErrorResponse(safeError, error);
231
246
  const parsedError = parseErrorResponseSafely(errorResponse, safeError);
232
247
 
@@ -49,7 +49,10 @@ function processValidationFailure(responseData) {
49
49
  function processValidationError(response) {
50
50
  const error = new Error(`Validation request failed: ${response.formattedError || response.error || 'Unknown error'}`);
51
51
  error.status = response.status || 400;
52
- error.data = response.data;
52
+ error.data = response.errorData || response.data;
53
+ if (response.formattedError) {
54
+ error.formatted = response.formattedError;
55
+ }
53
56
  throw error;
54
57
  }
55
58
 
@@ -128,25 +128,45 @@ function buildVerificationUrlWithUserCode(verificationUri, userCode) {
128
128
  }
129
129
 
130
130
  /**
131
- * Displays device code information to the user
132
- * Formats user code and verification URL for easy reading. Uses a URL with user_code in the query
133
- * so the device page can pre-fill the code and the user does not need to type it.
131
+ * Full visit URL for device login (complete URI when the controller sends one).
132
+ *
133
+ * @param {Object} deviceCodeResponse
134
+ * @param {string} [deviceCodeResponse.verification_uri_complete]
135
+ * @param {string} [deviceCodeResponse.verification_uri]
136
+ * @param {string} [deviceCodeResponse.user_code]
137
+ * @returns {string}
138
+ */
139
+ function resolveDeviceVisitUrl(deviceCodeResponse) {
140
+ const complete = deviceCodeResponse && deviceCodeResponse.verification_uri_complete;
141
+ if (complete) {
142
+ return complete;
143
+ }
144
+ return buildVerificationUrlWithUserCode(
145
+ deviceCodeResponse && deviceCodeResponse.verification_uri,
146
+ deviceCodeResponse && deviceCodeResponse.user_code
147
+ );
148
+ }
149
+
150
+ /**
151
+ * Displays device code information to the user.
152
+ * Prints a visit URL with user_code so the device page can pre-fill the code.
134
153
  *
135
154
  * @function displayDeviceCodeInfo
136
155
  * @param {string} userCode - User code to display
137
156
  * @param {string} verificationUri - Verification URL (base, without user_code)
138
157
  * @param {Object} logger - Logger instance with log method
139
158
  * @param {Object} chalk - Chalk instance for colored output
159
+ * @param {string} [verificationUriComplete] - Full verification URL when provided by the controller
140
160
  */
141
- function displayDeviceCodeInfo(userCode, verificationUri, logger, chalk) {
142
- const visitUrl = buildVerificationUrlWithUserCode(verificationUri, userCode);
161
+ function displayDeviceCodeInfo(userCode, verificationUri, logger, chalk, verificationUriComplete) {
162
+ const visitUrl = verificationUriComplete
163
+ || buildVerificationUrlWithUserCode(verificationUri, userCode);
143
164
  logger.log(chalk.cyan('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
144
165
  logger.log(chalk.cyan(' Device Code Flow Authentication'));
145
166
  logger.log(chalk.cyan('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'));
146
- logger.log(chalk.yellow('To complete authentication:'));
147
- logger.log(chalk.gray(' 1. Visit (code is in the URL): ') + chalk.blue.underline(visitUrl));
148
- logger.log(chalk.gray(' 2. Approve the request\n'));
149
- logger.log(chalk.gray('Waiting for approval...'));
167
+ logger.log(chalk.yellow('Open this URL to authenticate:'));
168
+ logger.log(chalk.blue.underline(visitUrl));
169
+ logger.log(chalk.gray('\nApprove the request in the browser. Waiting for approval...'));
150
170
  }
151
171
 
152
172
  /** Timeout for token refresh request (ms). Longer than default to allow for slow controller/Keycloak. */
@@ -197,5 +217,6 @@ module.exports = {
197
217
  displayDeviceCodeInfo,
198
218
  refreshDeviceToken,
199
219
  parseTokenResponse,
200
- buildVerificationUrlWithUserCode
220
+ buildVerificationUrlWithUserCode,
221
+ resolveDeviceVisitUrl
201
222
  };