@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.
- package/lib/agent-kit/host-approve.js +48 -6
- package/lib/agent-kit/init.js +2 -2
- package/lib/agent-kit/remote-prompts.js +46 -13
- package/lib/agent-kit/run.js +1 -1
- package/lib/agent-kit/start.js +310 -0
- package/lib/app/deploy.js +7 -4
- package/lib/app/offer-create-from-template.js +184 -0
- package/lib/cli/setup-app.js +8 -1
- package/lib/cli/setup-auth.js +11 -2
- package/lib/commands/agent-kit-help.js +74 -4
- package/lib/commands/agent-kit.js +83 -12
- package/lib/commands/auth-status-user.js +99 -0
- package/lib/commands/auth-status.js +14 -4
- package/lib/commands/datasource-load-export-cli.js +7 -2
- package/lib/commands/datasource-test-trust-cli.js +5 -1
- package/lib/commands/governance-command-action.js +5 -4
- package/lib/commands/identity-membership-role-cache.js +6 -1
- package/lib/commands/lifecycle-command-action.js +16 -15
- package/lib/commands/login-credentials.js +8 -3
- package/lib/commands/login-device.js +14 -5
- package/lib/commands/login.js +14 -1
- package/lib/commands/logout.js +50 -15
- package/lib/commands/role-assistant.js +24 -12
- package/lib/commands/verify-governance-command-action.js +9 -7
- package/lib/commands/verify-operations-command-action.js +9 -8
- package/lib/commands/verify-trust-command-action.js +6 -5
- package/lib/external-system/download.js +1 -2
- package/lib/generator/helpers.js +12 -1
- package/lib/integration-definition/apply-static-enterprise.js +5 -2
- package/lib/role-assistant/knowledge-sync-settings-push.js +172 -0
- package/lib/role-assistant/knowledge-sync.js +9 -126
- package/lib/role-assistant/test-cases-evaluate.js +12 -1
- package/lib/role-assistant/test-cases-evidence.js +44 -5
- package/lib/role-assistant/test-cases-lifecycle.js +9 -3
- package/lib/role-assistant/test-runner.js +3 -0
- package/lib/schema/infra-parameter.schema.json +51 -3
- package/lib/schema/infra.parameter.yaml +476 -40
- package/lib/schema/type/document-storage.json +1 -1
- package/lib/utils/api-required-permissions.js +271 -0
- package/lib/utils/api.js +16 -13
- package/lib/utils/cli-json-exit.js +41 -0
- package/lib/utils/cli-utils.js +23 -5
- package/lib/utils/controller-url.js +78 -4
- package/lib/utils/deployment-errors.js +15 -0
- package/lib/utils/deployment-validation-helpers.js +4 -1
- package/lib/utils/device-code.js +31 -10
- package/lib/utils/error-formatters/error-parser.js +16 -1
- package/lib/utils/error-formatters/permission-errors.js +19 -5
- package/lib/utils/keycloak-realm-ready.js +5 -2
- package/lib/utils/logger.js +1 -0
- package/lib/utils/paths.js +26 -5
- package/lib/utils/secrets-helpers.js +2 -2
- package/lib/utils/write-stream-and-wait.js +63 -0
- package/package.json +20 -6
- package/templates/agent-kit/agent-kit.yaml +1 -1
- package/templates/agent-kit/instructions/AGENTKIT.md +25 -4
- package/templates/agent-kit/instructions/root.AGENTS.md +1 -1
- package/templates/agent-kit/skills/aifabrix-connected-system/SKILL.md +2 -1
- package/templates/agent-kit/skills/aifabrix-connected-system/references/delivery-gates.md +1 -1
- package/templates/agent-kit/skills/aifabrix-plan/SKILL.md +2 -1
- package/templates/agent-kit/skills/aifabrix-prove/SKILL.md +1 -0
- package/templates/agent-kit/skills/aifabrix-role-assistant/SKILL.md +1 -0
- package/templates/agent-kit/skills/shared/hosts.md +13 -26
- package/templates/agent-kit/skills/shared/login.md +16 -0
- package/templates/agent-kit/workspace/README.md +1 -1
- package/templates/applications/dataplane/application.yaml +1 -2
- package/templates/applications/dataplane/env.template +5 -5
- package/templates/applications/miso-controller/application.yaml +21 -2
- package/templates/applications/miso-controller/env.template +89 -17
- 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
|
|
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
|
|
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 };
|
package/lib/utils/cli-utils.js
CHANGED
|
@@ -83,9 +83,11 @@ function isPortConflictError(errorMsg) {
|
|
|
83
83
|
* @returns {boolean} True if permission denied error
|
|
84
84
|
*/
|
|
85
85
|
function isPermissionDeniedError(errorMsg) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
'
|
|
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
|
-
*
|
|
136
|
-
*
|
|
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
|
|
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(
|
|
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
|
|
package/lib/utils/device-code.js
CHANGED
|
@@ -128,25 +128,45 @@ function buildVerificationUrlWithUserCode(verificationUri, userCode) {
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
/**
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
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 =
|
|
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('
|
|
147
|
-
logger.log(chalk.
|
|
148
|
-
logger.log(chalk.gray('
|
|
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
|
};
|