@aifabrix/builder 2.62.0 → 2.63.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,117 @@
1
+ /**
2
+ * Read the public Miso required-baseline onboarding projection.
3
+ *
4
+ * @fileoverview Public Miso onboarding readiness client
5
+ * @author AI Fabrix Team
6
+ * @version 1.0.0
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const { ApiClient } = require('./index');
12
+
13
+ const ENDPOINT = '/api/v1/health/onboarding-status';
14
+ const PROJECTION_KEYS = ['code', 'retryable', 'state'];
15
+ const VALID_PROJECTIONS = new Set([
16
+ 'pending|null|true',
17
+ 'complete|null|false',
18
+ 'incomplete|baseline-dimensions-incomplete|true',
19
+ 'incomplete|baseline-roles-incomplete|true'
20
+ ]);
21
+ const VALID_PAIRS = new Set([
22
+ 'pending|null|true::pending|null|true',
23
+ 'incomplete|baseline-dimensions-incomplete|true::pending|null|true',
24
+ 'complete|null|false::pending|null|true',
25
+ 'complete|null|false::incomplete|baseline-roles-incomplete|true',
26
+ 'complete|null|false::complete|null|false'
27
+ ]);
28
+
29
+ function onboardingStatusError(message, code = 'MISO_ONBOARDING_STATUS_INVALID') {
30
+ const error = new Error(message);
31
+ error.code = code;
32
+ return error;
33
+ }
34
+
35
+ function projectionSignature(projection) {
36
+ return `${projection.state}|${projection.code === null ? 'null' : projection.code}|${projection.retryable}`;
37
+ }
38
+
39
+ function hasValidProjectionTypes(projection) {
40
+ return typeof projection.state === 'string' &&
41
+ (projection.code === null || typeof projection.code === 'string') &&
42
+ typeof projection.retryable === 'boolean';
43
+ }
44
+
45
+ function parseProjection(value, name) {
46
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
47
+ throw onboardingStatusError(`Miso ${name} readiness projection is missing or malformed.`);
48
+ }
49
+ const keys = Object.keys(value).sort();
50
+ if (keys.length !== PROJECTION_KEYS.length || keys.some((key, index) => key !== PROJECTION_KEYS[index])) {
51
+ throw onboardingStatusError(`Miso ${name} readiness projection has unsupported fields.`);
52
+ }
53
+ const projection = { state: value.state, code: value.code, retryable: value.retryable };
54
+ if (!hasValidProjectionTypes(projection)) {
55
+ throw onboardingStatusError(`Miso ${name} readiness projection has invalid field types.`);
56
+ }
57
+ if (!VALID_PROJECTIONS.has(projectionSignature(projection))) {
58
+ throw onboardingStatusError(`Miso ${name} readiness projection has an unsupported state.`);
59
+ }
60
+ return Object.freeze(projection);
61
+ }
62
+
63
+ function parseOnboardingStatusResponse(response) {
64
+ if (!response || response.success !== true || response.status !== 200) {
65
+ throw onboardingStatusError(
66
+ 'Miso onboarding readiness is unavailable.',
67
+ 'MISO_ONBOARDING_STATUS_UNAVAILABLE'
68
+ );
69
+ }
70
+ const wireBody = response.data;
71
+ if (!wireBody || typeof wireBody !== 'object' || Array.isArray(wireBody) ||
72
+ Object.prototype.hasOwnProperty.call(wireBody, 'success')) {
73
+ throw onboardingStatusError('Miso onboarding readiness response envelope is malformed.');
74
+ }
75
+ const data = wireBody.data;
76
+ if (!data || typeof data !== 'object' || Array.isArray(data) ||
77
+ Object.prototype.hasOwnProperty.call(data, 'success')) {
78
+ throw onboardingStatusError('Miso onboarding readiness data envelope is malformed.');
79
+ }
80
+ const baselineDimensions = parseProjection(data.baselineDimensions, 'Dimension');
81
+ const baselineRoles = parseProjection(data.baselineRoles, 'Business Role');
82
+ const pair = `${projectionSignature(baselineDimensions)}::${projectionSignature(baselineRoles)}`;
83
+ if (!VALID_PAIRS.has(pair)) {
84
+ throw onboardingStatusError('Miso onboarding readiness contains an unsupported step ordering.');
85
+ }
86
+ return Object.freeze({ baselineDimensions, baselineRoles });
87
+ }
88
+
89
+ /**
90
+ * Fetch required-baseline readiness from Miso Controller.
91
+ * GET /api/v1/health/onboarding-status
92
+ * @requiresPermission {Controller} Public
93
+ * @param {string} controllerUrl
94
+ * @param {{timeoutMs?: number, client?: ApiClient}} [options]
95
+ * @returns {Promise<{baselineDimensions: Object, baselineRoles: Object}>}
96
+ */
97
+ async function fetchOnboardingStatus(controllerUrl, options = {}) {
98
+ const client = options.client || new ApiClient(controllerUrl);
99
+ const requestOptions = {};
100
+ if (options.timeoutMs !== undefined) requestOptions.timeoutMs = options.timeoutMs;
101
+ let response;
102
+ try {
103
+ response = await client.get(ENDPOINT, requestOptions);
104
+ } catch {
105
+ throw onboardingStatusError(
106
+ 'Miso onboarding readiness request failed.',
107
+ 'MISO_ONBOARDING_STATUS_UNAVAILABLE'
108
+ );
109
+ }
110
+ return parseOnboardingStatusResponse(response);
111
+ }
112
+
113
+ module.exports = {
114
+ ENDPOINT,
115
+ fetchOnboardingStatus,
116
+ parseOnboardingStatusResponse
117
+ };
@@ -28,6 +28,24 @@ const { refreshUrlsLocalRegistryFromBuilder } = require('../utils/urls-local-reg
28
28
  const { resolveControllerUrl } = require('../utils/controller-url');
29
29
  const config = require('../core/config');
30
30
 
31
+ /**
32
+ * Handle local credential persistence failure without exposing details in strict mode.
33
+ * @param {Error} error
34
+ * @param {boolean} required
35
+ * @returns {false}
36
+ */
37
+ function handleCredentialPersistenceFailure(error, required) {
38
+ if (required) {
39
+ const persistenceError = new Error(
40
+ 'Application credentials could not be saved to the supported local secret store.'
41
+ );
42
+ persistenceError.code = 'LOCAL_CREDENTIAL_PERSISTENCE_FAILED';
43
+ throw persistenceError;
44
+ }
45
+ logger.warn(chalk.yellow(`⚠ Could not save credentials locally: ${error.message}`));
46
+ return false;
47
+ }
48
+
31
49
  /**
32
50
  * Build registration data payload from app configuration
33
51
  * @param {Object} appConfig - Application configuration
@@ -83,8 +101,11 @@ function buildRegistrationData(appConfig, options) {
83
101
  * @async
84
102
  * @param {Object} responseData - Registration response data
85
103
  * @param {string} apiUrl - API URL
104
+ * @param {Object} [options] - Persistence behavior
105
+ * @param {boolean} [options.required=false] - Throw a safe typed error when persistence fails
106
+ * @returns {Promise<boolean>} True when credentials were persisted
86
107
  */
87
- async function saveLocalCredentials(responseData, apiUrl) {
108
+ async function saveLocalCredentials(responseData, apiUrl, options = {}) {
88
109
  const registeredAppKey = responseData.application.key;
89
110
  const clientIdKey = `${registeredAppKey}-client-idKeyVault`;
90
111
  const clientSecretKey = `${registeredAppKey}-client-secretKeyVault`;
@@ -113,8 +134,9 @@ async function saveLocalCredentials(responseData, apiUrl) {
113
134
  logger.log(formatSuccessParagraph('Credentials saved to ~/.aifabrix/secrets.local.yaml\n'));
114
135
  }
115
136
  } catch (error) {
116
- logger.warn(chalk.yellow(`⚠ Could not save credentials locally: ${error.message}`));
137
+ return handleCredentialPersistenceFailure(error, options.required === true);
117
138
  }
139
+ return true;
118
140
  }
119
141
 
120
142
  /**
@@ -20,6 +20,11 @@ const secretsEnsure = require('../core/secrets-ensure');
20
20
  const secretsEnvWrite = require('../core/secrets-env-write');
21
21
  const { getContainerPort } = require('../utils/port-resolver');
22
22
  const { applyAdminSecretsPlatformOverlay } = require('../core/admin-secrets-env-overlay');
23
+ const {
24
+ resolveDimensionAbacSetting,
25
+ applyDimensionAbacToEnvMap
26
+ } = require('../utils/dataplane-dimension-abac-setting');
27
+ const { resolveEffectiveRegistry } = require('../utils/resolve-docker-image-ref');
23
28
 
24
29
  /**
25
30
  * Merge app env with admin-secrets; overlay ensures platform keys come from admin file.
@@ -38,6 +43,13 @@ async function applyOnboardingPlatformModeIfNeeded(appName, merged) {
38
43
  merged.ONBOARDING_FULL_PLATFORM = mode === 'full' ? 'true' : 'false';
39
44
  }
40
45
 
46
+ async function applyDimensionAbacSettingIfNeeded(appName, merged) {
47
+ if (appName !== 'dataplane') return;
48
+ const userConfig = await config.getConfig();
49
+ const resolved = resolveDimensionAbacSetting(userConfig);
50
+ applyDimensionAbacToEnvMap(appName, merged, resolved.value);
51
+ }
52
+
41
53
  /**
42
54
  * Backfill only secrets needed for `aifabrix run <app>`: kv:// keys from that app's env.template
43
55
  * (catalog defaults via placeholderContext). Does not run full `ensureInfraSecrets`.
@@ -260,6 +272,29 @@ async function applyPostgresPasswordFromPgpass(merged, developerId) {
260
272
  if (pgpassPwd !== undefined) merged.POSTGRES_PASSWORD = pgpassPwd;
261
273
  }
262
274
 
275
+ /**
276
+ * Deliver existing local installation inputs to privileged Miso bootstrap only.
277
+ * @param {string} appName
278
+ * @param {Object} merged
279
+ * @param {Object} appConfig
280
+ * @param {Object|null} scopeOpts
281
+ */
282
+ function applyMisoBootstrapInputsIfNeeded(appName, merged, appConfig, scopeOpts) {
283
+ if (appName !== 'miso-controller') return;
284
+ const postgresPassword = merged.POSTGRES_PASSWORD;
285
+ if (typeof postgresPassword !== 'string' || postgresPassword.trim().length === 0) {
286
+ throw new Error(
287
+ 'Local PostgreSQL administrator password is unavailable. Re-run "aifabrix up-infra" before starting Miso Controller.'
288
+ );
289
+ }
290
+ merged.POSTGRES_ADMIN_PASSWORD = postgresPassword;
291
+ const { registry } = resolveEffectiveRegistry(appConfig, {
292
+ registry: scopeOpts?.registry
293
+ });
294
+ if (registry) merged.ACR_LOGIN_SERVER = registry;
295
+ else delete merged.ACR_LOGIN_SERVER;
296
+ }
297
+
263
298
  /**
264
299
  * @param {string} runEnvPath
265
300
  * @param {string} runEnvAdminPath
@@ -300,8 +335,10 @@ async function buildMergedRunEnvAndWrite(appName, appConfig, devDir, developerId
300
335
  mergeRunEnvWithAdminOverlay(merged, adminObj, appName);
301
336
  await applyOnboardingPlatformModeIfNeeded(appName, merged);
302
337
  await applyPostgresPasswordFromPgpass(merged, developerId);
338
+ applyMisoBootstrapInputsIfNeeded(appName, merged, appConfig, scopeOpts);
303
339
  injectDatabaseNamesAndUsers(merged, appConfig, scopeOpts);
304
340
  injectContainerPortForRun(merged, appConfig, appName);
341
+ await applyDimensionAbacSettingIfNeeded(appName, merged);
305
342
 
306
343
  const runEnvPath = path.join(devDir, '.env.run');
307
344
  const runEnvAdminPath = path.join(devDir, '.env.run.admin');
@@ -351,9 +388,9 @@ async function ensureRunEnvFilesPresent(
351
388
  * @throws {Error} If password keys appear in environment-like assignment
352
389
  */
353
390
  function assertNoPasswordLiteralsInCompose(composeContent) {
354
- const badPattern = /\n\s+(-?\s*)(POSTGRES_PASSWORD|DB_\d+_PASSWORD)\s*[:=]/;
391
+ const badPattern = /\n\s+(-?\s*)(POSTGRES_PASSWORD|POSTGRES_ADMIN_PASSWORD|DB_\d+_PASSWORD)\s*[:=]/;
355
392
  if (badPattern.test(composeContent)) {
356
- throw new Error('Generated compose must not contain password literals (POSTGRES_PASSWORD, DB_*_PASSWORD). Use env_file only.');
393
+ throw new Error('Generated compose must not contain password literals (PostgreSQL or application database passwords). Use env_file only.');
357
394
  }
358
395
  }
359
396
 
@@ -26,6 +26,7 @@ function buildRunEnvRecovery(appName, prepared) {
26
26
  prepared.appConfig.developerId,
27
27
  {
28
28
  runEnvKey: prepared.mergedRunOptions.env,
29
+ registry: prepared.mergedRunOptions.registry,
29
30
  effectiveEnvironmentScopedResources:
30
31
  prepared.mergedRunOptions.effectiveEnvironmentScopedResources
31
32
  }
@@ -366,7 +366,7 @@ async function prepareEnvironment(appName, appConfig, options) {
366
366
  appConfig,
367
367
  devDir,
368
368
  developerId,
369
- { runEnvKey, effectiveEnvironmentScopedResources }
369
+ { runEnvKey, effectiveEnvironmentScopedResources, registry: options.registry }
370
370
  );
371
371
  const { hydrateMissingLocalParameters } = require('./run-parameter-sync');
372
372
  await hydrateMissingLocalParameters(appName, runEnvPath);
@@ -49,6 +49,34 @@ const {
49
49
  emitSystemBuilderAppManifestLineIfTTY
50
50
  } = require('../utils/manifest-source-emit');
51
51
  const { flushDeferredPublicUrlWarnings } = require('./infra-guided-public-url-warn');
52
+ const { fetchOnboardingStatus } = require('../api/onboarding-status.api');
53
+ const {
54
+ waitForRequiredBaselineReadiness
55
+ } = require('../commands/setup-onboarding-readiness');
56
+
57
+ async function runRequiredBaselineReadinessIfNeeded(options, authOpts) {
58
+ if (!options || options.requireBaselineReadiness !== true) return;
59
+ const controllerUrl = await resolveGuidedAuthControllerUrl(authOpts);
60
+ const spin = startSpinner('Waiting for required Miso baseline onboarding...');
61
+ const onProgress = createSpinnerTextUpdater(spin);
62
+ try {
63
+ await waitForRequiredBaselineReadiness({
64
+ request: requestOptions => fetchOnboardingStatus(controllerUrl, requestOptions),
65
+ onProgress
66
+ });
67
+ } catch (error) {
68
+ if (spin && typeof spin.fail === 'function') spin.fail('Miso baseline onboarding not ready');
69
+ throw error;
70
+ }
71
+ stopSpinnerSuccess(spin, 'Miso baseline onboarding ready');
72
+ logger.log('');
73
+ }
74
+
75
+ async function runGuidedMisoAndReadinessPhase(options, handleUpMiso, authOpts) {
76
+ await withMutedLogger(() => prepareUrlsLocalRegistryForUpPlatform());
77
+ await runGuidedKeycloakAndMisoControllerPhase(options, handleUpMiso);
78
+ await runRequiredBaselineReadinessIfNeeded(options, authOpts);
79
+ }
52
80
 
53
81
  /**
54
82
  * Guided Keycloak → Miso Controller start with one spinner per app (no second health wait).
@@ -327,8 +355,7 @@ async function runGuidedUpPlatform(options, handleUpMiso, handleUpDataplane, han
327
355
  skipLoginIfAuthenticated: options && options.skipLoginIfAuthenticated === true
328
356
  };
329
357
 
330
- await withMutedLogger(() => prepareUrlsLocalRegistryForUpPlatform());
331
- await runGuidedKeycloakAndMisoControllerPhase(options, handleUpMiso);
358
+ await runGuidedMisoAndReadinessPhase(options, handleUpMiso, authOpts);
332
359
  await runGuidedKeycloakRealmPhase(options);
333
360
  await runGuidedAuthStep(handleLogin, authOpts, { startSpinner, stopSpinnerSuccess });
334
361
  const controllerUrl = await resolveGuidedAuthControllerUrl(authOpts);
@@ -388,6 +415,7 @@ module.exports = {
388
415
  runGuidedUpMiso,
389
416
  runGuidedUpDataplane,
390
417
  runGuidedUpPlatform,
418
+ runRequiredBaselineReadinessIfNeeded,
391
419
  runGuidedDownInfra,
392
420
  stripHealthPrefix
393
421
  };
@@ -4,7 +4,7 @@
4
4
  * `setup` is the one-shot platform installer. When no infrastructure is
5
5
  * detected it runs a small wizard (admin email/password, AI tool keys) and
6
6
  * then `up-infra` + `up-platform`. When infra is already up, it offers a
7
- * mode menu (re-install, wipe data, clean install files, update images).
7
+ * mode menu (re-install, wipe data, update images).
8
8
  *
9
9
  * `teardown` is the symmetrical inverse of `setup`: `down-infra -v` plus a
10
10
  * full clean of the AI Fabrix system directory (everything except
@@ -20,6 +20,7 @@
20
20
  const { handleCommandError } = require('../utils/cli-utils');
21
21
  const { handleSetup } = require('../commands/setup');
22
22
  const { handleTeardown } = require('../commands/teardown');
23
+ const { parseDimensionAbacOption } = require('../utils/dataplane-dimension-abac-setting');
23
24
 
24
25
  const SETUP_HELP_AFTER = `
25
26
  What this command does:
@@ -27,16 +28,25 @@ What this command does:
27
28
  - With infra running: shows a mode menu:
28
29
  1) Re-install — stop infra and remove all volumes, then up-infra + up-platform --force.
29
30
  2) Wipe data — drop every database and DB user, then up-infra + up-platform --force.
30
- 3) Clean installation files — remove user-local secrets, then up-infra + up-platform --force.
31
- 4) Update images — pull the latest infra and platform images, then up-infra + up-platform.
31
+ 3) Update images — pull the latest infra and platform images, then up-infra + up-platform.
32
32
  - With --mode fresh: first-time install when no infra is running (non-interactive; use with --admin-email and --admin-password).
33
33
  - With --mode update-images: refresh images when infra is already running (non-interactive).
34
34
 
35
35
  AI tool keys are read from the user-local file (~/.aifabrix/secrets.local.yaml) merged with the shared aifabrix-secrets file. If either source already provides keys, the AI tool prompt is skipped.
36
36
 
37
+ Dimension ABAC defaults to off. Use --dimension-abac on|off to persist an explicit Dataplane selection. The saved selection is reused by later setup and redeploy flows; changing it takes effect after the normal Dataplane restart/redeploy boundary.
38
+
37
39
  Use 'aifabrix teardown' to fully remove the local installation.
38
40
  `;
39
41
 
42
+ function parseRepeatedDimensionAbacOption(raw, previous) {
43
+ const value = parseDimensionAbacOption(raw);
44
+ if (previous !== undefined && previous !== value) {
45
+ throw new Error('Conflicting --dimension-abac values. Supply only on or only off.');
46
+ }
47
+ return value;
48
+ }
49
+
40
50
  const TEARDOWN_HELP_AFTER = `
41
51
  What this command does:
42
52
  - Runs down-infra -v (stops all infra + apps, removes every Docker volume).
@@ -64,6 +74,11 @@ function registerSetup(program) {
64
74
  .option('--integration-server', 'Install the integration server (runs once in the miso environment)')
65
75
  .option('--no-integration-server', 'Do not install the integration server (default)')
66
76
  .option('--mode <mode>', 'Non-interactive mode: fresh (no infra) or update-images (infra running)')
77
+ .option(
78
+ '--dimension-abac <off|on>',
79
+ 'Persist Dataplane Dimension ABAC selection (default: off)',
80
+ parseRepeatedDimensionAbacOption
81
+ )
67
82
  .option('-y, --yes', 'Skip destructive confirmation prompts (re-install, wipe data, teardown)')
68
83
  .addHelpText('after', SETUP_HELP_AFTER)
69
84
  .action(async(options) => {
@@ -296,7 +296,8 @@ async function runUpPlatform(opts = {}) {
296
296
  base: true,
297
297
  platformControllerUrl: authCtx.platformControllerUrl,
298
298
  skipLoginIfAuthenticated: authCtx.skipLoginIfAuthenticated,
299
- requireKeycloakRealm: opts.requireKeycloakRealm === true
299
+ requireKeycloakRealm: opts.requireKeycloakRealm === true,
300
+ requireBaselineReadiness: true
300
301
  },
301
302
  upMiso.handleUpMiso,
302
303
  upDataplane.handleUpDataplane,
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Bounded observer for required Miso baseline onboarding.
3
+ *
4
+ * @fileoverview Setup-only Miso baseline readiness observer
5
+ * @author AI Fabrix Team
6
+ * @version 1.0.0
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const { performance } = require('node:perf_hooks');
12
+
13
+ const DEFAULT_INTERVAL_MS = 2000;
14
+ const DEFAULT_REQUEST_TIMEOUT_MS = 5000;
15
+ const DEFAULT_DEADLINE_MS = 90000;
16
+
17
+ function monotonicNow() {
18
+ return performance.now();
19
+ }
20
+
21
+ function readinessError(message, code) {
22
+ const error = new Error(message);
23
+ error.code = code;
24
+ return error;
25
+ }
26
+
27
+ function classifyReadiness(snapshot) {
28
+ const dimensions = snapshot.baselineDimensions;
29
+ const roles = snapshot.baselineRoles;
30
+ if (dimensions.state === 'incomplete') {
31
+ throw readinessError(
32
+ `Required baseline Dimensions are incomplete (${dimensions.code}). Retry setup after reconciliation completes.`,
33
+ dimensions.code
34
+ );
35
+ }
36
+ if (roles.state === 'incomplete') {
37
+ throw readinessError(
38
+ `Required baseline Business Roles are incomplete (${roles.code}). Retry setup after reconciliation completes.`,
39
+ roles.code
40
+ );
41
+ }
42
+ if (dimensions.state === 'complete' && roles.state === 'complete') return 'complete';
43
+ return dimensions.state === 'pending' ? 'dimensions' : 'roles';
44
+ }
45
+
46
+ function timeoutError() {
47
+ return readinessError(
48
+ 'Timed out waiting for required Miso baseline onboarding. Retry setup after reconciliation completes.',
49
+ 'MISO_BASELINE_READINESS_TIMEOUT'
50
+ );
51
+ }
52
+
53
+ async function waitForRequiredBaselineReadiness(options) {
54
+ const request = options.request;
55
+ const now = options.now || monotonicNow;
56
+ const sleep = options.sleep || (ms => new Promise(resolve => setTimeout(resolve, ms)));
57
+ const onProgress = options.onProgress || (() => {});
58
+ const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
59
+ const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
60
+ const deadlineMs = options.deadlineMs ?? DEFAULT_DEADLINE_MS;
61
+ const startedAt = now();
62
+ while (now() - startedAt < deadlineMs) {
63
+ const remaining = deadlineMs - (now() - startedAt);
64
+ if (remaining <= 0) throw timeoutError();
65
+ const snapshot = await request({ timeoutMs: Math.min(requestTimeoutMs, remaining) });
66
+ const state = classifyReadiness(snapshot);
67
+ if (state === 'complete') return snapshot;
68
+ onProgress(state === 'dimensions'
69
+ ? 'Waiting for required baseline Dimensions...'
70
+ : 'Waiting for required baseline Business Roles...');
71
+ const afterRequest = deadlineMs - (now() - startedAt);
72
+ if (afterRequest <= 0) throw timeoutError();
73
+ await sleep(Math.min(intervalMs, afterRequest));
74
+ }
75
+ throw timeoutError();
76
+ }
77
+
78
+ module.exports = {
79
+ DEFAULT_INTERVAL_MS,
80
+ DEFAULT_REQUEST_TIMEOUT_MS,
81
+ DEFAULT_DEADLINE_MS,
82
+ waitForRequiredBaselineReadiness
83
+ };
@@ -36,6 +36,11 @@ const setupModes = require('./setup-modes');
36
36
  const { syncPlatformControllerUrlsInConfig } = require('./setup-platform-auth');
37
37
  const installationLog = require('../utils/installation-log');
38
38
  const { beginSetupCommandFlow, endSetupCommandFlow } = require('./setup-run-context');
39
+ const {
40
+ parseDimensionAbacOption,
41
+ resolveDimensionAbacSetting,
42
+ persistExplicitDimensionAbacSetting
43
+ } = require('../utils/dataplane-dimension-abac-setting');
39
44
 
40
45
  const MODE = setupPrompts.MODE;
41
46
 
@@ -87,7 +92,20 @@ function normalizeCliMode(options) {
87
92
  * @param {'fresh'|'update-images'} cliMode
88
93
  * @returns {Promise<{ outcome: string, setupMode: string }>}
89
94
  */
90
- async function runSetupWithCliMode(options, cliMode) {
95
+ async function persistDimensionAbacSelection(settingContext) {
96
+ await persistExplicitDimensionAbacSetting(config, settingContext.explicitValue);
97
+ }
98
+
99
+ async function prepareDimensionAbacSetting(options) {
100
+ const explicitValue = parseDimensionAbacOption(options.dimensionAbac);
101
+ const userConfig = await config.getConfig();
102
+ const resolved = resolveDimensionAbacSetting(userConfig, explicitValue);
103
+ const displayValue = resolved.value ? 'on' : 'off';
104
+ logger.log(infoLine(`Dimension ABAC: ${displayValue} (${resolved.source}).`));
105
+ return { explicitValue, ...resolved };
106
+ }
107
+
108
+ async function runSetupWithCliMode(options, cliMode, settingContext) {
91
109
  logSetupHeader();
92
110
  logger.log(formatProgress('Detecting current installation state...'));
93
111
  const running = await isInfraRunning();
@@ -97,12 +115,13 @@ async function runSetupWithCliMode(options, cliMode) {
97
115
  'Infrastructure is already running; use --mode update-images or run teardown first.'
98
116
  );
99
117
  }
100
- await runFreshInstallFlow(options);
118
+ await runFreshInstallFlow(options, settingContext);
101
119
  return { outcome: 'success', setupMode: CLI_SETUP_MODES.FRESH };
102
120
  }
103
121
  if (!running) {
104
122
  throw new Error('No infrastructure running; use --mode fresh for first-time install.');
105
123
  }
124
+ await persistDimensionAbacSelection(settingContext);
106
125
  await dispatchMode(MODE.UPDATE_IMAGES);
107
126
  logger.log(formatSuccessParagraph(`aifabrix setup complete (mode: ${cliMode}).`));
108
127
  return { outcome: 'success', setupMode: cliMode };
@@ -120,12 +139,13 @@ async function applySetupPlatformMode(options) {
120
139
  return mode;
121
140
  }
122
141
 
123
- async function runFreshInstallFlow(options) {
142
+ async function runFreshInstallFlow(options, settingContext) {
124
143
  await maybePinDeveloperIdForFreshInstall(options.developer);
125
144
  logger.log(infoLine('No infrastructure detected; starting fresh install.'));
126
145
  await applySetupPlatformMode(options);
127
146
  const adminCreds = await setupPrompts.promptAdminCredentials(options);
128
147
  await config.setAdminEmail(adminCreds.adminEmail);
148
+ await persistDimensionAbacSelection(settingContext);
129
149
  await setupModes.runFreshInstall(adminCreds);
130
150
  logger.log(formatSuccessParagraph('aifabrix setup complete (fresh install).'));
131
151
  }
@@ -134,7 +154,7 @@ async function runFreshInstallFlow(options) {
134
154
  * @param {boolean} assumeYes
135
155
  * @returns {Promise<{ aborted: boolean, mode: string|null }>}
136
156
  */
137
- async function runExistingInfraFlow(assumeYes) {
157
+ async function runExistingInfraFlow(assumeYes, settingContext) {
138
158
  const mode = await setupPrompts.promptModeSelection();
139
159
  const proceed = await setupPrompts.confirmDestructiveMode(mode, assumeYes);
140
160
  if (!proceed) {
@@ -145,6 +165,7 @@ async function runExistingInfraFlow(assumeYes) {
145
165
  if (mode === MODE.REINSTALL || mode === MODE.WIPE_DATA) {
146
166
  await applySetupPlatformMode({});
147
167
  }
168
+ await persistDimensionAbacSelection(settingContext);
148
169
  await dispatchMode(mode);
149
170
  logger.log(formatSuccessParagraph(`aifabrix setup complete (mode: ${mode}).`));
150
171
  return { aborted: false, mode };
@@ -197,20 +218,19 @@ async function dispatchMode(mode) {
197
218
  * @param {boolean} assumeYes
198
219
  * @returns {Promise<{ outcome: string, setupMode: string|null }>}
199
220
  */
200
- async function runSetupMainPath(options, assumeYes) {
201
- const cliMode = normalizeCliMode(options);
221
+ async function runSetupMainPath(options, assumeYes, cliMode, settingContext) {
202
222
  if (cliMode) {
203
- return runSetupWithCliMode(options, cliMode);
223
+ return runSetupWithCliMode(options, cliMode, settingContext);
204
224
  }
205
225
 
206
226
  logSetupHeader();
207
227
  logger.log(formatProgress('Detecting current installation state...'));
208
228
  const running = await isInfraRunning();
209
229
  if (!running) {
210
- await runFreshInstallFlow(options);
230
+ await runFreshInstallFlow(options, settingContext);
211
231
  return { outcome: 'success', setupMode: 'fresh' };
212
232
  }
213
- const r = await runExistingInfraFlow(assumeYes);
233
+ const r = await runExistingInfraFlow(assumeYes, settingContext);
214
234
  const outcome = r.aborted ? 'aborted' : 'success';
215
235
  return { outcome, setupMode: r.mode };
216
236
  }
@@ -300,7 +320,9 @@ async function handleSetup(options = {}) {
300
320
 
301
321
  beginSetupCommandFlow();
302
322
  try {
303
- const main = await runSetupMainPath(options, assumeYes);
323
+ const cliMode = normalizeCliMode(options);
324
+ const settingContext = await prepareDimensionAbacSetting(options);
325
+ const main = await runSetupMainPath(options, assumeYes, cliMode, settingContext);
304
326
  outcome = main.outcome;
305
327
  setupMode = main.setupMode;
306
328
  } catch (e) {
@@ -19,6 +19,11 @@ const {
19
19
  extractAppConfiguration
20
20
  } = require('../utils/app-register-config');
21
21
  const { buildRegistrationData, saveLocalCredentials, applyLocalhostPortAdjustment } = require('../app/register');
22
+ const {
23
+ loadDataplanePipelineCredentialsFromSecretsFile,
24
+ credentialsAcceptToken,
25
+ waitForDataplaneCredentials
26
+ } = require('./up-dataplane-credentials');
22
27
  const { formatSuccessLine } = require('../utils/cli-test-layout-chalk');
23
28
  const { getToken } = require('../api/auth.api');
24
29
  const { updateApplicationSelfStatus } = require('../api/applications.api');
@@ -31,6 +36,111 @@ const ENCRYPTION_VAULT_KEYS = [
31
36
  'secrets-encryptionKeyVault'
32
37
  ];
33
38
 
39
+ const BOOTSTRAP_CREDENTIAL_ERROR_CODE = 'DATAPLANE_BOOTSTRAP_CREDENTIALS_UNAVAILABLE';
40
+
41
+ /**
42
+ * Create a stable, secret-safe bootstrap credential error.
43
+ * @param {string} message
44
+ * @returns {Error}
45
+ */
46
+ function createBootstrapCredentialError(message) {
47
+ const error = new Error(message);
48
+ error.code = BOOTSTRAP_CREDENTIAL_ERROR_CODE;
49
+ return error;
50
+ }
51
+
52
+ /**
53
+ * @param {Object|null|undefined} credentials
54
+ * @returns {boolean}
55
+ */
56
+ function hasUsablePipelineCredentials(credentials) {
57
+ return Boolean(
58
+ credentials &&
59
+ typeof credentials.clientId === 'string' && credentials.clientId.trim() &&
60
+ typeof credentials.clientSecret === 'string' && credentials.clientSecret.trim()
61
+ );
62
+ }
63
+
64
+ /**
65
+ * Persist newly issued credentials and prove they can be loaded before deployment.
66
+ * @async
67
+ * @param {Object} responseData
68
+ * @param {string} controllerUrl
69
+ * @param {string} environmentKey
70
+ * @param {number} containerPort
71
+ * @returns {Promise<{status: string, credentialsAvailable: true}>}
72
+ */
73
+ async function persistBootstrapCredentials(responseData, controllerUrl, environmentKey, containerPort) {
74
+ try {
75
+ await saveLocalCredentials(responseData, controllerUrl, { required: true });
76
+ } catch {
77
+ throw createBootstrapCredentialError(
78
+ 'Dataplane bootstrap credentials could not be persisted locally; deployment was not started.'
79
+ );
80
+ }
81
+ const localCredentials = await loadDataplanePipelineCredentialsFromSecretsFile();
82
+ if (!hasUsablePipelineCredentials(localCredentials)) {
83
+ throw createBootstrapCredentialError(
84
+ 'Dataplane bootstrap credentials are not available from the supported local secret store; ' +
85
+ 'deployment was not started.'
86
+ );
87
+ }
88
+ await syncDataplaneApplicationUrls(
89
+ controllerUrl,
90
+ environmentKey,
91
+ responseData.credentials,
92
+ containerPort
93
+ );
94
+ if (!(await waitForDataplaneCredentials(controllerUrl))) {
95
+ throw createBootstrapCredentialError(
96
+ 'Dataplane bootstrap credentials were issued but the controller did not accept them in time. ' +
97
+ 'Deployment was not started; retry the command after checking the controller token endpoint.'
98
+ );
99
+ }
100
+ logger.log(formatSuccessLine('Dataplane bootstrap credentials saved locally'));
101
+ return { status: 'provisioned', credentialsAvailable: true };
102
+ }
103
+
104
+ /**
105
+ * Require existing supported local credentials for an idempotent response.
106
+ * @async
107
+ * @returns {Promise<{status: string, credentialsAvailable: true}>}
108
+ */
109
+ async function acceptAlreadyProvisionedDataplane(controllerUrl) {
110
+ const localCredentials = await loadDataplanePipelineCredentialsFromSecretsFile();
111
+ const accepted = hasUsablePipelineCredentials(localCredentials) &&
112
+ await credentialsAcceptToken(controllerUrl);
113
+ if (!accepted) {
114
+ throw createBootstrapCredentialError(
115
+ 'Dataplane is registered on the controller but pipeline credentials are missing locally. ' +
116
+ 'Use an authorized session to run: aifabrix app rotate-secret dataplane, then retry setup.'
117
+ );
118
+ }
119
+ logger.log(formatSuccessLine('Dataplane already provisioned (bootstrap idempotent)'));
120
+ return { status: 'already_provisioned', credentialsAvailable: true };
121
+ }
122
+
123
+ /**
124
+ * @async
125
+ * @param {Object} responseData
126
+ * @param {string} controllerUrl
127
+ * @param {string} environmentKey
128
+ * @param {number} containerPort
129
+ * @returns {Promise<{status: string, credentialsAvailable: true}>}
130
+ */
131
+ async function handleBootstrapResponse(responseData, controllerUrl, environmentKey, containerPort) {
132
+ if (hasUsablePipelineCredentials(responseData.credentials)) {
133
+ return persistBootstrapCredentials(responseData, controllerUrl, environmentKey, containerPort);
134
+ }
135
+ if (responseData.status === 'already_provisioned') {
136
+ return acceptAlreadyProvisionedDataplane(controllerUrl);
137
+ }
138
+ throw createBootstrapCredentialError(
139
+ 'Dataplane bootstrap returned no usable credentials or supported already-provisioned status; ' +
140
+ 'deployment was not started.'
141
+ );
142
+ }
143
+
34
144
  /**
35
145
  * @param {Record<string, unknown>} secrets
36
146
  * @returns {string|null}
@@ -118,41 +228,6 @@ async function syncDataplaneApplicationUrls(controllerUrl, environmentKey, crede
118
228
  }
119
229
  }
120
230
 
121
- async function handleBootstrapResponse(responseData, controllerUrl, environmentKey, containerPort) {
122
- if (responseData.credentials && responseData.credentials.clientSecret) {
123
- await saveLocalCredentials(responseData, controllerUrl);
124
- await syncDataplaneApplicationUrls(
125
- controllerUrl,
126
- environmentKey,
127
- responseData.credentials,
128
- containerPort
129
- );
130
- logger.log(formatSuccessLine('Dataplane bootstrap credentials saved locally'));
131
- const { waitForDataplaneCredentials } = require('./up-dataplane-credentials');
132
- if (!(await waitForDataplaneCredentials(controllerUrl))) {
133
- throw new Error(
134
- 'Dataplane bootstrap credentials were issued but the controller did not accept them in time. ' +
135
- 'The deploy that follows gets one attempt, so it would fail with a less specific error. ' +
136
- 'Retry the command; if it persists, check the controller is serving /api/v1/auth/token.'
137
- );
138
- }
139
- return;
140
- }
141
- if (responseData.status === 'already_provisioned') {
142
- logger.log(formatSuccessLine('Dataplane already provisioned (bootstrap idempotent)'));
143
- const { credentialsAcceptToken } = require('./up-dataplane-credentials');
144
- if (await credentialsAcceptToken(controllerUrl)) return;
145
- throw new Error(
146
- 'Dataplane is registered on the controller but local pipeline credentials are missing or ' +
147
- 'not accepted by it. Run: aifabrix login && aifabrix app rotate-secret dataplane'
148
- );
149
- }
150
- throw new Error(
151
- 'Dataplane bootstrap returned no credentials and no "already_provisioned" status ' +
152
- `(status: ${responseData.status || 'none'}). The controller may be a version this CLI does not support.`
153
- );
154
- }
155
-
156
231
  /**
157
232
  * Register dataplane via bootstrap endpoint (idempotent).
158
233
  * @async
@@ -160,7 +235,7 @@ async function handleBootstrapResponse(responseData, controllerUrl, environmentK
160
235
  * @param {string} controllerUrl
161
236
  * @param {string} environmentKey
162
237
  * @param {Function} buildDataplaneImageRefFn
163
- * @returns {Promise<void>}
238
+ * @returns {Promise<{status: string, credentialsAvailable: true}>}
164
239
  */
165
240
  async function bootstrapDataplaneApplication(options, controllerUrl, environmentKey, buildDataplaneImageRefFn) {
166
241
  const encryptionKey = await resolveBootstrapEncryptionKey();
@@ -193,7 +268,7 @@ async function bootstrapDataplaneApplication(options, controllerUrl, environment
193
268
  registrationData
194
269
  );
195
270
 
196
- await handleBootstrapResponse(responseData, controllerUrl, environmentKey, containerPort);
271
+ return handleBootstrapResponse(responseData, controllerUrl, environmentKey, containerPort);
197
272
  }
198
273
 
199
274
  module.exports = {
@@ -201,5 +276,7 @@ module.exports = {
201
276
  resolveBootstrapEncryptionKey,
202
277
  bootstrapDataplaneApplication,
203
278
  syncDataplaneApplicationUrls,
204
- pickBootstrapEncryptionKeyFromSecrets
279
+ pickBootstrapEncryptionKeyFromSecrets,
280
+ hasUsablePipelineCredentials,
281
+ BOOTSTRAP_CREDENTIAL_ERROR_CODE
205
282
  };
@@ -344,34 +344,8 @@ async function provisionAndDeployDataplane(options, environmentKey, controllerUr
344
344
  logger.log('');
345
345
  }
346
346
 
347
- /**
348
- * Handle up-dataplane command: ensure logged in, environment dev, ensure dataplane,
349
- * register or rotate (if already registered), deploy (send manifest to controller),
350
- * then run dataplane app locally (always local deployment).
351
- *
352
- * @async
353
- * @function handleUpDataplane
354
- * @param {Object} options - Commander options
355
- * @param {string} [options.registry] - Override registry for dataplane
356
- * @param {string} [options.registryMode] - Override registry mode (acr|external)
357
- * @param {string} [options.image] - Override image reference for dataplane
358
- * @param {boolean} [options.skipInfraCheck] - When true, skip Postgres/Redis gate (caller already verified, e.g. guided up-dataplane).
359
- * @returns {Promise<void>}
360
- * @throws {Error} If infra not up, controller unavailable, not logged in, environment not dev, or any step fails
361
- */
362
- /**
363
- * Run options for the local dataplane container.
364
- *
365
- * A guided platform install already shows one spinner for the whole dataplane step, so it
366
- * either forwards its own progress callback or asks for silence; a standalone run prints
367
- * normally.
368
- *
369
- * @param {Object} options - Command options
370
- * @param {string} runEnvKey - Resolved run environment
371
- * @param {number|undefined} portOverride - Resolved port override
372
- * @returns {Object} Options for app.runApp
373
- */
374
- function buildDataplaneRunOptions(options, runEnvKey, portOverride) {
347
+ async function runDataplaneLocally(options) {
348
+ const { runEnvKey, portOverride } = await computeDataplaneRunOverrides(options);
375
349
  const runAppOpts = {
376
350
  skipEnvOutputPath: true,
377
351
  env: runEnvKey,
@@ -387,7 +361,7 @@ function buildDataplaneRunOptions(options, runEnvKey, portOverride) {
387
361
  runAppOpts.silentPoll = true;
388
362
  }
389
363
  }
390
- return runAppOpts;
364
+ await app.runApp('dataplane', runAppOpts);
391
365
  }
392
366
 
393
367
  async function handleUpDataplane(options = {}) {
@@ -420,8 +394,7 @@ async function handleUpDataplane(options = {}) {
420
394
  controllerUrl
421
395
  );
422
396
 
423
- const { runEnvKey, portOverride } = await computeDataplaneRunOverrides(options);
424
- await app.runApp('dataplane', buildDataplaneRunOptions(options, runEnvKey, portOverride));
397
+ await runDataplaneLocally(options);
425
398
 
426
399
  if (options.platformInstall !== true) {
427
400
  logger.log(formatSuccessParagraph('up-dataplane complete. Dataplane is registered, deployed in dev, and running locally.'));
@@ -22,6 +22,22 @@ const { generateControllerManifest } = require('./external-controller-manifest')
22
22
  const { resolveVersionForApp } = require('../utils/image-version');
23
23
  const { getContainerPort } = require('../utils/port-resolver');
24
24
  const { buildEnvVarMap } = require('../utils/env-map');
25
+ const config = require('../core/config');
26
+ const {
27
+ resolveDimensionAbacSetting,
28
+ applyDimensionAbacToConfiguration
29
+ } = require('../utils/dataplane-dimension-abac-setting');
30
+
31
+ async function resolveDimensionAbacValue(appName) {
32
+ if (appName !== 'dataplane') return undefined;
33
+ const userConfig = await config.getConfig();
34
+ return resolveDimensionAbacSetting(userConfig).value;
35
+ }
36
+
37
+ async function applyResolvedDimensionAbacToConfiguration(appName, configuration) {
38
+ const value = await resolveDimensionAbacValue(appName);
39
+ if (value !== undefined) applyDimensionAbacToConfiguration(appName, configuration, value);
40
+ }
25
41
  const { mergeAdditionalPublishedPorts } = require('../utils/docker-manifest-public-port');
26
42
 
27
43
  /**
@@ -115,6 +131,13 @@ function substitutePortInDeployment(obj, portNumber) {
115
131
  }
116
132
  }
117
133
 
134
+ function normalizeDeploymentPort(deployment, effectivePort) {
135
+ if (deployment.port === undefined) return;
136
+ deployment.port = typeof deployment.port === 'string' && /^\d+$/.test(deployment.port)
137
+ ? parseInt(deployment.port, 10)
138
+ : (typeof deployment.port === 'number' ? deployment.port : effectivePort);
139
+ }
140
+
118
141
  /** Regex to find ${VAR} placeholders for env substitution */
119
142
  const ENV_VAR_PLACEHOLDER_REGEX = /\$\{([^}]+)\}/g;
120
143
 
@@ -179,6 +202,9 @@ function buildAndValidateDeployment(appName, variables, envTemplate, rbac, optio
179
202
  // Parse environment variables from template and merge portalInput from application config.
180
203
  // A known deployment target resolves manifest {NAME} values; otherwise they stay for the Controller.
181
204
  const configuration = parseEnvironmentVariables(envTemplate, variables, options && options.target);
205
+ if (options && options.dimensionAbacValue !== undefined) {
206
+ applyDimensionAbacToConfiguration(appName, configuration, options.dimensionAbacValue);
207
+ }
182
208
 
183
209
  // Build deployment manifest (Controller computes deploymentKey from schema)
184
210
  const deployment = builders.buildManifestStructure(appName, variables, configuration, rbac);
@@ -193,10 +219,7 @@ function buildAndValidateDeployment(appName, variables, envTemplate, rbac, optio
193
219
  // Replace ${PORT} with port from application.yaml so manifest deploys correctly
194
220
  const effectivePort = getEffectivePortForSubstitution(variables, 3000);
195
221
  substitutePortInDeployment(deployment, effectivePort);
196
- if (deployment.port !== undefined) {
197
- deployment.port = typeof deployment.port === 'string' && /^\d+$/.test(deployment.port)
198
- ? parseInt(deployment.port, 10) : (typeof deployment.port === 'number' ? deployment.port : effectivePort);
199
- }
222
+ normalizeDeploymentPort(deployment, effectivePort);
200
223
 
201
224
  // Resolve ${REDIS_HOST}, ${DB_HOST}, etc. from env-config.yaml so manifest has no unresolved vars
202
225
  const envVarMap = options && options.envVarMap;
@@ -239,14 +262,12 @@ async function buildDeploymentManifestInMemory(appName, options = {}) {
239
262
  app: { ...variables.app, version: resolved.version }
240
263
  };
241
264
  const configuration = parseEnvironmentVariables(envTemplate, variables, options && options.target);
265
+ await applyResolvedDimensionAbacToConfiguration(appName, configuration);
242
266
  const deployment = builders.buildManifestStructure(appName, variablesWithVersion, configuration, rbac);
243
267
 
244
268
  const effectivePort = getEffectivePortForSubstitution(variablesWithVersion, 3000);
245
269
  substitutePortInDeployment(deployment, effectivePort);
246
- if (deployment.port !== undefined) {
247
- deployment.port = typeof deployment.port === 'string' && /^\d+$/.test(deployment.port)
248
- ? parseInt(deployment.port, 10) : (typeof deployment.port === 'number' ? deployment.port : effectivePort);
249
- }
270
+ normalizeDeploymentPort(deployment, effectivePort);
250
271
  const envVarMap = await buildEnvVarMap('docker', null, null, { appPort: effectivePort });
251
272
  await mergeAdditionalPublishedPorts(envVarMap, variablesWithVersion);
252
273
  substituteEnvVarsInDeployment(deployment, envVarMap);
@@ -302,10 +323,12 @@ async function writeRegularDeployJson(appName, appPath, appType, options = {}) {
302
323
  };
303
324
  const effectivePort = getEffectivePortForSubstitution(variablesWithVersion, 3000);
304
325
  const envVarMap = await buildEnvVarMap('docker', null, null, { appPort: effectivePort });
326
+ const dimensionAbacValue = await resolveDimensionAbacValue(appName);
305
327
  await mergeAdditionalPublishedPorts(envVarMap, variablesWithVersion);
306
328
  const deployment = buildAndValidateDeployment(appName, variablesWithVersion, envTemplate, rbac, {
307
329
  envVarMap,
308
- target: options.target
330
+ target: options.target,
331
+ dimensionAbacValue
309
332
  });
310
333
  const jsonContent = JSON.stringify(deployment, null, 2);
311
334
  fs.writeFileSync(jsonPath, jsonContent, { mode: 0o644 });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Resolve and materialize the Dataplane Dimension ABAC installer setting.
3
+ *
4
+ * @fileoverview Strict non-secret Dimension ABAC setting helpers
5
+ * @author AI Fabrix Team
6
+ * @version 1.0.0
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const SETTING_NAME = 'DIMENSION_ABAC_ENFORCEMENT_ENABLED';
12
+ const CONFIG_KEY = 'dimensionAbacEnforcementEnabled';
13
+ const DATAPLANE_APP = 'dataplane';
14
+
15
+ function settingError(message) {
16
+ const error = new Error(message);
17
+ error.code = 'DIMENSION_ABAC_SETTING_INVALID';
18
+ return error;
19
+ }
20
+
21
+ function parseDimensionAbacOption(raw) {
22
+ if (raw === undefined || raw === null) return undefined;
23
+ if (raw === true || raw === false) return raw;
24
+ const value = String(raw).trim().toLowerCase();
25
+ if (value === 'on') return true;
26
+ if (value === 'off') return false;
27
+ throw settingError('Invalid --dimension-abac value. Use on or off.');
28
+ }
29
+
30
+ function readStoredValue(userConfig) {
31
+ const applications = userConfig && userConfig.applications;
32
+ if (applications === undefined || applications === null) return undefined;
33
+ if (typeof applications !== 'object' || Array.isArray(applications)) {
34
+ throw settingError('Invalid applications configuration; expected an object.');
35
+ }
36
+ const entry = applications[DATAPLANE_APP];
37
+ if (entry === undefined || entry === null) return undefined;
38
+ if (typeof entry !== 'object' || Array.isArray(entry)) {
39
+ throw settingError('Invalid applications.dataplane configuration; expected an object.');
40
+ }
41
+ if (!Object.prototype.hasOwnProperty.call(entry, CONFIG_KEY)) return undefined;
42
+ if (typeof entry[CONFIG_KEY] !== 'boolean') {
43
+ throw settingError(`Invalid applications.dataplane.${CONFIG_KEY}; expected a boolean.`);
44
+ }
45
+ return entry[CONFIG_KEY];
46
+ }
47
+
48
+ function resolveDimensionAbacSetting(userConfig, explicitValue) {
49
+ const stored = readStoredValue(userConfig);
50
+ if (explicitValue !== undefined && typeof explicitValue !== 'boolean') {
51
+ throw settingError('Explicit Dimension ABAC selection must be a boolean.');
52
+ }
53
+ if (explicitValue !== undefined) return { value: explicitValue, source: 'explicit' };
54
+ if (stored !== undefined) return { value: stored, source: 'stored' };
55
+ return { value: false, source: 'default' };
56
+ }
57
+
58
+ async function persistExplicitDimensionAbacSetting(configApi, value) {
59
+ if (value === undefined) return undefined;
60
+ if (!configApi || typeof configApi.getConfig !== 'function' || typeof configApi.saveConfig !== 'function') {
61
+ throw settingError('Configuration API is unavailable.');
62
+ }
63
+ if (typeof value !== 'boolean') {
64
+ throw settingError('Persisted Dimension ABAC selection must be a boolean.');
65
+ }
66
+ const userConfig = await configApi.getConfig();
67
+ readStoredValue(userConfig);
68
+ const applications = { ...(userConfig.applications || {}) };
69
+ const dataplane = { ...(applications[DATAPLANE_APP] || {}), [CONFIG_KEY]: value };
70
+ const nextConfig = { ...userConfig, applications: { ...applications, [DATAPLANE_APP]: dataplane } };
71
+ await configApi.saveConfig(nextConfig);
72
+ return nextConfig;
73
+ }
74
+
75
+ function toDimensionAbacEnvValue(value) {
76
+ if (typeof value !== 'boolean') {
77
+ throw settingError('Dimension ABAC value must be a boolean.');
78
+ }
79
+ return value ? 'true' : 'false';
80
+ }
81
+
82
+ function applyDimensionAbacToEnvMap(appName, envMap, value) {
83
+ if (appName !== DATAPLANE_APP) return envMap;
84
+ if (!envMap || typeof envMap !== 'object' || Array.isArray(envMap)) {
85
+ throw settingError('Dataplane environment map must be an object.');
86
+ }
87
+ envMap[SETTING_NAME] = toDimensionAbacEnvValue(value);
88
+ return envMap;
89
+ }
90
+
91
+ function applyDimensionAbacToConfiguration(appName, configuration, value) {
92
+ if (appName !== DATAPLANE_APP) return configuration;
93
+ if (!Array.isArray(configuration)) {
94
+ throw settingError('Dataplane deployment configuration must be an array.');
95
+ }
96
+ const matches = configuration.filter(item => item && item.name === SETTING_NAME);
97
+ if (matches.length > 1) {
98
+ throw settingError(`Dataplane deployment configuration contains duplicate ${SETTING_NAME} entries.`);
99
+ }
100
+ const entry = {
101
+ name: SETTING_NAME,
102
+ value: toDimensionAbacEnvValue(value),
103
+ location: 'variable',
104
+ required: false
105
+ };
106
+ if (matches.length === 0) configuration.push(entry);
107
+ else Object.assign(matches[0], entry);
108
+ return configuration;
109
+ }
110
+
111
+ module.exports = {
112
+ SETTING_NAME,
113
+ CONFIG_KEY,
114
+ parseDimensionAbacOption,
115
+ resolveDimensionAbacSetting,
116
+ persistExplicitDimensionAbacSetting,
117
+ toDimensionAbacEnvValue,
118
+ applyDimensionAbacToEnvMap,
119
+ applyDimensionAbacToConfiguration
120
+ };
@@ -13,6 +13,9 @@
13
13
  /** Env key patterns that indicate a secret (mask value) — same rules as aifabrix logs <app>. */
14
14
  const SECRET_KEY_PATTERN = /password|secret|token|credential|api[_-]?key/i;
15
15
 
16
+ /** Bounded encryption/master-key names; avoids treating every `key` variable as secret. */
17
+ const ENCRYPTION_KEY_PATTERN = /(?:^|[_-])(?:encryption|master)[_-]?key(?:$|[_-])/i;
18
+
16
19
  /** Prefixes to strip before checking key (avoids masking KEYCLOAK_SERVER_URL etc.) */
17
20
  const KEY_PREFIXES_TO_STRIP = /^KEYCLOAK_|^KEY_VAULT_/;
18
21
 
@@ -50,7 +53,7 @@ function maskEnvLine(line) {
50
53
  const value = line.slice(eq + 1);
51
54
 
52
55
  const keyForCheck = key.replace(KEY_PREFIXES_TO_STRIP, '');
53
- const isSecretKey = SECRET_KEY_PATTERN.test(keyForCheck);
56
+ const isSecretKey = SECRET_KEY_PATTERN.test(keyForCheck) || ENCRYPTION_KEY_PATTERN.test(keyForCheck);
54
57
 
55
58
  const maskedValue = maskUrlEmbeddedCredentials(value);
56
59
  const hasUrlCredentials = maskedValue !== value;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aifabrix/builder",
3
- "version": "2.62.0",
3
+ "version": "2.63.0",
4
4
  "description": "AI Fabrix Builder — CLI and developer scripts (pnpm + af)",
5
5
  "main": "lib/index.js",
6
6
  "bin": {
@@ -42,6 +42,7 @@
42
42
  "check:cli-help": "node scripts/check-cli-help-examples.js",
43
43
  "check:mirrored-templates": "node scripts/check-mirrored-templates.js",
44
44
  "test:manual": "jest --config jest.config.manual.js --runInBand",
45
+ "test:manual:public": "jest --config jest.config.manual-public.js --runInBand",
45
46
  "test:integration": "jest --config jest.config.integration.js --runInBand",
46
47
  "test:integration:fixtures": "jest --config jest.config.integration.fixtures.js --runInBand",
47
48
  "test:hubspot-wizard": "node integration/hubspot-test/test.js",
@@ -26,6 +26,12 @@ Application templates are folder-based and located under `templates/applications
26
26
  | `aifabrix-miso/packages/miso-controller/configs/infra.parameter.yaml` | `lib/schema/infra.parameter.yaml` |
27
27
  | `aifabrix-dataplane/builder/dataplane/` (`env.template`, `rbac.yaml`, `application.yaml`) | `templates/applications/dataplane/` |
28
28
 
29
+ The Dataplane `env.template` is the source of truth for
30
+ `DIMENSION_ABAC_ENFORCEMENT_ENABLED` and its default `false`. Do not hand-edit
31
+ the bundled mirror. `aifabrix setup --dimension-abac on|off` stores an operator
32
+ selection outside the template and overlays the resolved lowercase value during
33
+ local runtime and deployment-manifest materialization.
34
+
29
35
  **Schema master (dataplane):** Also copies dataplane `app/schemas/json/` core integration schemas + `type/*` and `configs/deployment-rules.yaml` into `lib/schema/` (same contract as dataplane `make sync-json`). Do not hand-edit those mirrors — edit dataplane SSOT, then sync. `lib/schema/infra.parameter.yaml` is the Miso catalog mirror above; other Builder-owned schemas in `lib/schema/` (wizard and the rest) are left alone.
30
36
 
31
37
  Overrides: `AIFABRIX_MISO_SRC`, `AIFABRIX_DATAPLANE_SRC` / `AIFABRIX_DATAPLANE_ROOT` (repo roots). Install with `aifabrix up-platform` / `up-builder-api` — do not hand-edit the template copies as primary.
@@ -5,7 +5,7 @@ app:
5
5
  description: 'Business Transformation services — HTTP orchestration for connected-system workspace lifecycle via Dataplane Enterprise MCP and governance APIs.'
6
6
  type: webapp
7
7
  language: typescript
8
- version: 2.62.0
8
+ version: 2.63.0
9
9
 
10
10
  # Image Configuration
11
11
  image: