@aifabrix/builder 2.42.0 → 2.43.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.
Files changed (133) hide show
  1. package/README.md +1 -1
  2. package/bin/aifabrix.js +1 -1
  3. package/integration/hubspot-test/README.md +126 -0
  4. package/integration/{hubspot → hubspot-test}/application.json +6 -6
  5. package/integration/{hubspot → hubspot-test}/create-hubspot.js +5 -5
  6. package/integration/hubspot-test/env.template +4 -0
  7. package/integration/{hubspot/hubspot-datasource-company.json → hubspot-test/hubspot-test-datasource-company.json} +3 -2
  8. package/integration/{hubspot/hubspot-datasource-contact.json → hubspot-test/hubspot-test-datasource-contact.json} +3 -2
  9. package/integration/{hubspot/hubspot-datasource-deal.json → hubspot-test/hubspot-test-datasource-deal.json} +3 -2
  10. package/integration/{hubspot/hubspot-datasource-users.json → hubspot-test/hubspot-test-datasource-users.json} +3 -2
  11. package/integration/{hubspot/hubspot-deploy.json → hubspot-test/hubspot-test-deploy.json} +198 -21
  12. package/integration/{hubspot/hubspot-system.json → hubspot-test/hubspot-test-system.json} +8 -7
  13. package/integration/hubspot-test/rbac.json +166 -0
  14. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-hubspot-credential-real.yaml +3 -3
  15. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-hubspot-env-vars.yaml +2 -2
  16. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-add-datasource.yaml +1 -1
  17. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-credential-create.yaml +1 -1
  18. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-credential-select.yaml +1 -1
  19. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-known-platform.yaml +1 -1
  20. package/integration/hubspot-test/test-artifacts/wizard-invalid-missing-source.yaml +2 -0
  21. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-mode.yaml +1 -1
  22. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-openapi-file.yaml +1 -1
  23. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-openapi-url.yaml +1 -1
  24. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-source.yaml +1 -1
  25. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-valid-for-dimension-array-test.yaml +1 -1
  26. package/integration/hubspot-test/test-artifacts/wizard-valid-for-dimension-key-test.yaml +5 -0
  27. package/integration/hubspot-test/test-artifacts/wizard-valid-for-dimension-path-test.yaml +5 -0
  28. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-valid-for-dimension-test.yaml +1 -1
  29. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-valid-for-rbac-test.yaml +1 -1
  30. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-valid-for-rbac-yaml-test.yaml +1 -1
  31. package/integration/{hubspot → hubspot-test}/test.js +102 -59
  32. package/integration/{hubspot → hubspot-test}/wizard-hubspot-e2e.yaml +2 -2
  33. package/integration/{hubspot → hubspot-test}/wizard-hubspot-platform.yaml +1 -1
  34. package/lib/api/external-test.api.js +1 -1
  35. package/lib/api/service-users.api.js +111 -2
  36. package/lib/api/types/service-users.types.js +41 -0
  37. package/lib/api/wizard.api.js +2 -1
  38. package/lib/app/index.js +2 -2
  39. package/lib/app/prompts.js +2 -2
  40. package/lib/app/readme.js +3 -1
  41. package/lib/app/register.js +3 -1
  42. package/lib/app/rotate-secret.js +3 -0
  43. package/lib/cli/setup-app.js +5 -5
  44. package/lib/cli/setup-auth.js +19 -11
  45. package/lib/cli/setup-dev.js +62 -32
  46. package/lib/cli/setup-environment.js +6 -21
  47. package/lib/cli/setup-infra.js +13 -0
  48. package/lib/cli/setup-secrets.js +45 -6
  49. package/lib/cli/setup-service-user.js +146 -20
  50. package/lib/cli/setup-utility.js +12 -0
  51. package/lib/commands/auth-config.js +25 -19
  52. package/lib/commands/datasource.js +46 -1
  53. package/lib/commands/dev-init.js +1 -1
  54. package/lib/commands/repair-env-template.js +14 -8
  55. package/lib/commands/repair-rbac.js +25 -19
  56. package/lib/commands/repair.js +108 -31
  57. package/lib/commands/secrets-remove.js +1 -1
  58. package/lib/commands/secrets-set.js +6 -0
  59. package/lib/commands/secrets-validate.js +17 -4
  60. package/lib/commands/service-user.js +231 -2
  61. package/lib/commands/up-common.js +25 -0
  62. package/lib/commands/up-dataplane.js +91 -7
  63. package/lib/commands/wizard-core-helpers.js +5 -2
  64. package/lib/commands/wizard-core.js +2 -1
  65. package/lib/commands/wizard-headless.js +6 -1
  66. package/lib/commands/wizard.js +13 -6
  67. package/lib/core/admin-secrets.js +2 -0
  68. package/lib/core/config.js +7 -5
  69. package/lib/core/ensure-encryption-key.js +1 -3
  70. package/lib/core/secrets.js +32 -9
  71. package/lib/core/templates.js +1 -1
  72. package/lib/datasource/abac-validator.js +157 -0
  73. package/lib/datasource/field-reference-validator.js +74 -36
  74. package/lib/datasource/log-viewer.js +221 -0
  75. package/lib/datasource/resolve-app.js +109 -0
  76. package/lib/datasource/test-e2e.js +11 -20
  77. package/lib/datasource/test-integration.js +42 -22
  78. package/lib/datasource/validate.js +5 -2
  79. package/lib/external-system/download-helpers.js +3 -1
  80. package/lib/external-system/generator.js +12 -8
  81. package/lib/external-system/test-system-level.js +1 -1
  82. package/lib/generator/external-controller-manifest.js +3 -3
  83. package/lib/generator/external-schema-utils.js +3 -1
  84. package/lib/generator/external.js +7 -7
  85. package/lib/generator/helpers.js +13 -9
  86. package/lib/generator/index.js +4 -4
  87. package/lib/generator/split.js +45 -10
  88. package/lib/generator/wizard-prompts-secondary.js +39 -7
  89. package/lib/generator/wizard-readme.js +4 -1
  90. package/lib/generator/wizard.js +68 -53
  91. package/lib/infrastructure/helpers.js +50 -35
  92. package/lib/infrastructure/index.js +39 -23
  93. package/lib/schema/env-config.yaml +19 -2
  94. package/lib/schema/external-datasource.schema.json +11 -1
  95. package/lib/schema/wizard-config.schema.json +7 -1
  96. package/lib/utils/app-config-resolver.js +23 -1
  97. package/lib/utils/config-paths.js +48 -4
  98. package/lib/utils/credential-secrets-env.js +16 -1
  99. package/lib/utils/env-map.js +7 -3
  100. package/lib/utils/error-formatter.js +37 -0
  101. package/lib/utils/external-env-template.js +180 -0
  102. package/lib/utils/external-readme.js +33 -1
  103. package/lib/utils/external-system-display.js +43 -0
  104. package/lib/utils/external-system-validators.js +2 -2
  105. package/lib/utils/help-builder.js +3 -5
  106. package/lib/utils/local-secrets.js +26 -3
  107. package/lib/utils/paths.js +2 -1
  108. package/lib/utils/secrets-generator.js +2 -2
  109. package/lib/utils/secrets-utils.js +4 -0
  110. package/lib/utils/secure-file-permissions.js +91 -0
  111. package/lib/utils/token-manager.js +36 -3
  112. package/lib/utils/yaml-preserve.js +59 -1
  113. package/lib/validation/env-template-auth.js +50 -2
  114. package/lib/validation/external-manifest-validator.js +8 -0
  115. package/lib/validation/validate.js +8 -0
  116. package/lib/validation/validator.js +10 -13
  117. package/package.json +6 -2
  118. package/templates/applications/dataplane/env.template +5 -1
  119. package/templates/applications/miso-controller/application.yaml +1 -1
  120. package/templates/applications/miso-controller/env.template +13 -2
  121. package/templates/external-system/README.md.hbs +18 -5
  122. package/templates/external-system/env.template.hbs +22 -0
  123. package/integration/hubspot/README.md +0 -100
  124. package/integration/hubspot/env.template +0 -4
  125. package/integration/hubspot/test-artifacts/wizard-invalid-missing-source.yaml +0 -2
  126. package/integration/hubspot/test-artifacts/wizard-valid-for-dimension-key-test.yaml +0 -5
  127. package/integration/hubspot/test-artifacts/wizard-valid-for-dimension-path-test.yaml +0 -5
  128. /package/integration/{hubspot → hubspot-test}/companies.json +0 -0
  129. /package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-app-name.yaml +0 -0
  130. /package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-missing-app.yaml +0 -0
  131. /package/integration/{hubspot → hubspot-test}/test-dataplane-down-helpers.js +0 -0
  132. /package/integration/{hubspot → hubspot-test}/test-dataplane-down-tests.js +0 -0
  133. /package/integration/{hubspot → hubspot-test}/test-dataplane-down.js +0 -0
@@ -37,8 +37,34 @@ const {
37
37
  startDockerServicesAndConfigure,
38
38
  checkInfraHealth
39
39
  } = require('./services');
40
+ const adminSecrets = require('../core/admin-secrets');
40
41
  // Lazy require to avoid circular dependency: infra -> app/down -> run-helpers -> infra
41
42
 
43
+ /**
44
+ * Runs a callback with a temporary .env.run file in infraDir (created from admin-secrets).
45
+ * Removes the file in a finally block.
46
+ * @async
47
+ * @param {string} infraDir - Infrastructure directory path
48
+ * @param {string} adminSecretsPath - Path to admin-secrets.env
49
+ * @param {function(string): Promise<void>} fn - Callback receiving runEnvPath
50
+ * @returns {Promise<void>}
51
+ */
52
+ async function withRunEnv(infraDir, adminSecretsPath, fn) {
53
+ const runEnvPath = path.join(infraDir, '.env.run');
54
+ try {
55
+ const adminObj = await adminSecrets.readAndDecryptAdminSecrets(adminSecretsPath);
56
+ const content = adminSecrets.envObjectToContent(adminObj);
57
+ fs.writeFileSync(runEnvPath, content, { mode: 0o600 });
58
+ await fn(runEnvPath);
59
+ } finally {
60
+ try {
61
+ if (fs.existsSync(runEnvPath)) fs.unlinkSync(runEnvPath);
62
+ } catch {
63
+ // Ignore unlink errors
64
+ }
65
+ }
66
+ }
67
+
42
68
  /**
43
69
  * Prepares infrastructure environment
44
70
  * Ensures infra secrets exist, then admin-secrets.env, then miso init script.
@@ -65,7 +91,7 @@ async function prepareInfrastructureEnvironment(developerId, options = {}) {
65
91
  }
66
92
 
67
93
  // Prepare infrastructure directory
68
- const { infraDir } = prepareInfraDirectory(devId, adminSecretsPath);
94
+ const { infraDir } = await prepareInfraDirectory(devId, adminSecretsPath);
69
95
  await ensureMisoInitScript(infraDir);
70
96
 
71
97
  return { devId, idNum, ports, templatePath, infraDir, adminSecretsPath };
@@ -174,8 +200,7 @@ async function removeAppVolumes(appNames, devId) {
174
200
  async function stopInfra() {
175
201
  const devId = await config.getDeveloperId();
176
202
  const aifabrixDir = paths.getAifabrixHome();
177
- const infraDirName = getInfraDirName(devId);
178
- const infraDir = path.join(aifabrixDir, infraDirName);
203
+ const infraDir = path.join(aifabrixDir, getInfraDirName(devId));
179
204
  const composePath = path.join(infraDir, 'compose.yaml');
180
205
  const adminSecretsPath = path.join(aifabrixDir, 'admin-secrets.env');
181
206
 
@@ -184,17 +209,15 @@ async function stopInfra() {
184
209
  return;
185
210
  }
186
211
 
187
- try {
212
+ await withRunEnv(infraDir, adminSecretsPath, async(runEnvPath) => {
188
213
  logger.log('Stopping application containers on the same network...');
189
214
  await stopAllAppContainers(devId);
190
215
  logger.log('Stopping infrastructure services...');
191
216
  const projectName = getInfraProjectName(devId);
192
217
  const composeCmd = await dockerUtils.getComposeCommand();
193
- await execAsyncWithCwd(`${composeCmd} -f "${composePath}" -p ${projectName} --env-file "${adminSecretsPath}" down`, { cwd: infraDir });
218
+ await execAsyncWithCwd(`${composeCmd} -f "${composePath}" -p ${projectName} --env-file "${runEnvPath}" down`, { cwd: infraDir });
194
219
  logger.log('Infrastructure services stopped');
195
- } finally {
196
- // Keep the compose file for future use
197
- }
220
+ });
198
221
  }
199
222
 
200
223
  /**
@@ -240,8 +263,7 @@ async function stopAllAppContainersAndVolumes(devId) {
240
263
  async function stopInfraWithVolumes() {
241
264
  const devId = await config.getDeveloperId();
242
265
  const aifabrixDir = paths.getAifabrixHome();
243
- const infraDirName = getInfraDirName(devId);
244
- const infraDir = path.join(aifabrixDir, infraDirName);
266
+ const infraDir = path.join(aifabrixDir, getInfraDirName(devId));
245
267
  const composePath = path.join(infraDir, 'compose.yaml');
246
268
  const adminSecretsPath = path.join(aifabrixDir, 'admin-secrets.env');
247
269
 
@@ -250,17 +272,15 @@ async function stopInfraWithVolumes() {
250
272
  return;
251
273
  }
252
274
 
253
- try {
275
+ await withRunEnv(infraDir, adminSecretsPath, async(runEnvPath) => {
254
276
  logger.log('Stopping application containers on the same network...');
255
277
  await stopAllAppContainersAndVolumes(devId);
256
278
  logger.log('Stopping infrastructure services and removing all data...');
257
279
  const projectName = getInfraProjectName(devId);
258
280
  const composeCmd = await dockerUtils.getComposeCommand();
259
- await execAsyncWithCwd(`${composeCmd} -f "${composePath}" -p ${projectName} --env-file "${adminSecretsPath}" down -v`, { cwd: infraDir });
281
+ await execAsyncWithCwd(`${composeCmd} -f "${composePath}" -p ${projectName} --env-file "${runEnvPath}" down -v`, { cwd: infraDir });
260
282
  logger.log('Infrastructure services stopped and all data removed');
261
- } finally {
262
- // Keep the compose file for future use
263
- }
283
+ });
264
284
  }
265
285
 
266
286
  /**
@@ -281,7 +301,6 @@ async function restartService(serviceName) {
281
301
  if (!serviceName || typeof serviceName !== 'string') {
282
302
  throw new Error('Service name is required and must be a string');
283
303
  }
284
-
285
304
  const validServices = ['postgres', 'redis', 'pgadmin', 'redis-commander', 'traefik'];
286
305
  if (!validServices.includes(serviceName)) {
287
306
  throw new Error(`Invalid service name. Must be one of: ${validServices.join(', ')}`);
@@ -289,8 +308,7 @@ async function restartService(serviceName) {
289
308
 
290
309
  const devId = await config.getDeveloperId();
291
310
  const aifabrixDir = paths.getAifabrixHome();
292
- const infraDirName = getInfraDirName(devId);
293
- const infraDir = path.join(aifabrixDir, infraDirName);
311
+ const infraDir = path.join(aifabrixDir, getInfraDirName(devId));
294
312
  const composePath = path.join(infraDir, 'compose.yaml');
295
313
  const adminSecretsPath = path.join(aifabrixDir, 'admin-secrets.env');
296
314
 
@@ -298,15 +316,13 @@ async function restartService(serviceName) {
298
316
  throw new Error('Infrastructure not properly configured');
299
317
  }
300
318
 
301
- try {
319
+ await withRunEnv(infraDir, adminSecretsPath, async(runEnvPath) => {
302
320
  logger.log(`Restarting ${serviceName} service...`);
303
321
  const projectName = getInfraProjectName(devId);
304
322
  const composeCmd = await dockerUtils.getComposeCommand();
305
- await execAsyncWithCwd(`${composeCmd} -f "${composePath}" -p ${projectName} --env-file "${adminSecretsPath}" restart ${serviceName}`, { cwd: infraDir });
323
+ await execAsyncWithCwd(`${composeCmd} -f "${composePath}" -p ${projectName} --env-file "${runEnvPath}" restart ${serviceName}`, { cwd: infraDir });
306
324
  logger.log(`${serviceName} service restarted successfully`);
307
- } finally {
308
- // Keep the compose file for future use
309
- }
325
+ });
310
326
  }
311
327
 
312
328
  // Re-export status helper functions
@@ -16,11 +16,19 @@ environments:
16
16
  REDIS_PORT: 6379 # Internal port (container-to-container). REDIS_PUBLIC_PORT calculated automatically.
17
17
  MISO_HOST: miso-controller
18
18
  MISO_PORT: 3000 # Internal port (container-to-container). MISO_PUBLIC_PORT calculated automatically.
19
+ MISO_PUBLIC_PORT: 3000
19
20
  KEYCLOAK_HOST: keycloak
20
- KEYCLOAK_PORT: 8082 # Internal port (container-to-container). KEYCLOAK_PUBLIC_PORT calculated automatically.
21
+ KEYCLOAK_PORT: 8080 # Internal port (container-to-container). KEYCLOAK_PUBLIC_PORT calculated automatically.
21
22
  KEYCLOAK_PUBLIC_PORT: 8082
23
+ MORI_HOST: mori-controller
24
+ MORI_PORT: 3004
25
+ OPENWEBUI_HOST: openwebui
26
+ OPENWEBUI_PORT: 3003
27
+ FLOWISE_HOST: flowise
28
+ FLOWISE_PORT: 3002
22
29
  DATAPLANE_HOST: dataplane
23
- DATAPLANE_PORT: 3001 # Internal port (container-to-container). DATAPLANE_PUBLIC_PORT calculated automatically.
30
+ DATAPLANE_PORT: 3001 # Internal port (container-to-container). DATAPLANE_PUBLIC_PORT calculated automatically.
31
+ DATAPLANE_PUBLIC_PORT: 3001
24
32
  NODE_ENV: production
25
33
  PYTHONUNBUFFERED: 1
26
34
  PYTHONDONTWRITEBYTECODE: 1
@@ -33,10 +41,19 @@ environments:
33
41
  REDIS_PORT: 6379
34
42
  MISO_HOST: localhost
35
43
  MISO_PORT: 3010
44
+ MISO_PUBLIC_PORT: 3010
36
45
  KEYCLOAK_HOST: localhost
37
46
  KEYCLOAK_PORT: 8082
47
+ KEYCLOAK_PUBLIC_PORT: 8082
48
+ MORI_HOST: localhost
49
+ MORI_PORT: 3014
50
+ OPENWEBUI_HOST: localhost
51
+ OPENWEBUI_PORT: 3013
52
+ FLOWISE_HOST: localhost
53
+ FLOWISE_PORT: 3012
38
54
  DATAPLANE_HOST: localhost
39
55
  DATAPLANE_PORT: 3011
56
+ DATAPLANE_PUBLIC_PORT: 3011
40
57
  NODE_ENV: development
41
58
  PYTHONUNBUFFERED: 1
42
59
  PYTHONDONTWRITEBYTECODE: 1
@@ -544,7 +544,7 @@
544
544
  "properties":{
545
545
  "rejectIf":{
546
546
  "type":"array",
547
- "description":"List of conditions that cause a record to be rejected.",
547
+ "description":"List of conditions that cause a record to be rejected. For lessThan: missing field is treated as reject. For greaterThan: missing field is not rejected. See quality docs for operator semantics.",
548
548
  "items":{
549
549
  "type":"object",
550
550
  "required":[
@@ -659,6 +659,11 @@
659
659
  "default":true,
660
660
  "description":"Enable two-phase sync pattern. When true: validates metadata first (quality rules, comparison with DocumentRecords), then fetches binaries via CIP for changed/new documents. When false: fetches binaries directly without metadata validation phase (single-phase sync). Note: Files are never synced back to external systems (one-way sync only: external → dataplane)."
661
661
  },
662
+ "ingestAfterSync":{
663
+ "type":"boolean",
664
+ "default":false,
665
+ "description":"When true, chunk and embed each document after store during sync so vector search returns hits immediately. When false, ingestion runs later (e.g. Celery task or on approval). Set true for E2E tests that validate vector step."
666
+ },
662
667
  "binaryOperationRef":{
663
668
  "type":"string",
664
669
  "default":"get",
@@ -700,6 +705,11 @@
700
705
  },
701
706
  "notifications":{
702
707
  "type":"object"
708
+ },
709
+ "ingestAfterSync":{
710
+ "type":"boolean",
711
+ "default":false,
712
+ "description":"When true, chunk and embed each document after store during sync so vector search returns hits."
703
713
  }
704
714
  },
705
715
  "additionalProperties":false
@@ -20,11 +20,17 @@
20
20
  },
21
21
  "systemIdOrKey": {
22
22
  "type": "string",
23
- "description": "Existing system ID or key (required when mode='add-datasource')",
23
+ "description": "Application/system key (e.g. hubspot-demo), not a datasource or entity key. Required when mode='add-datasource'. Must be the system key from the dataplane, not an entity key (e.g. 'companies').",
24
24
  "pattern": "^[a-z0-9-]+$",
25
25
  "minLength": 1,
26
26
  "maxLength": 50
27
27
  },
28
+ "systemDisplayName": {
29
+ "type": ["string", "null"],
30
+ "title": "Systemdisplayname",
31
+ "description": "System-level display name for the credential (e.g. 'Hubspot Demo'). When the OpenAPI title is entity-specific (e.g. 'Companies'), pass the system name here so authentication.displayName is system-level.",
32
+ "maxLength": 200
33
+ },
28
34
  "source": {
29
35
  "type": "object",
30
36
  "description": "Source configuration for the wizard",
@@ -49,4 +49,26 @@ function resolveApplicationConfigPath(appPath) {
49
49
  );
50
50
  }
51
51
 
52
- module.exports = { resolveApplicationConfigPath };
52
+ const RBAC_NAMES = ['rbac.yaml', 'rbac.yml', 'rbac.json'];
53
+
54
+ /**
55
+ * Resolves path to RBAC config file (rbac.yaml, rbac.yml, or rbac.json).
56
+ * Returns the first path that exists; no renames or migrations.
57
+ *
58
+ * @param {string} appPath - Absolute path to application directory
59
+ * @returns {string|null} Absolute path to RBAC file, or null if none exist
60
+ */
61
+ function resolveRbacPath(appPath) {
62
+ if (!appPath || typeof appPath !== 'string') {
63
+ throw new Error('App path is required and must be a string');
64
+ }
65
+ for (const name of RBAC_NAMES) {
66
+ const candidate = path.join(appPath, name);
67
+ if (fs.existsSync(candidate)) {
68
+ return candidate;
69
+ }
70
+ }
71
+ return null;
72
+ }
73
+
74
+ module.exports = { resolveApplicationConfigPath, resolveRbacPath };
@@ -56,30 +56,73 @@ async function setPathConfig(getConfigFn, saveConfigFn, key, value, errorMsg) {
56
56
  await saveConfigFn(config);
57
57
  }
58
58
 
59
+ /**
60
+ * Clear a path config key (set to undefined so getPathConfig returns null).
61
+ * @param {Function} getConfigFn - Function to get config
62
+ * @param {Function} saveConfigFn - Function to save config
63
+ * @param {string} key - Configuration key
64
+ * @returns {Promise<void>}
65
+ */
66
+ async function clearPathConfig(getConfigFn, saveConfigFn, key) {
67
+ const config = await getConfigFn();
68
+ config[key] = undefined;
69
+ await saveConfigFn(config);
70
+ }
71
+
59
72
  function createHomeAndSecretsPathFunctions(getConfigFn, saveConfigFn) {
60
73
  return {
61
74
  async getAifabrixHomeOverride() {
62
75
  return getPathConfig(getConfigFn, 'aifabrix-home');
63
76
  },
64
77
  async setAifabrixHomeOverride(homePath) {
65
- await setPathConfig(getConfigFn, saveConfigFn, 'aifabrix-home', homePath, 'Home path is required and must be a string');
78
+ if (typeof homePath !== 'string') {
79
+ throw new Error('Home path is required and must be a string');
80
+ }
81
+ const trimmed = homePath.trim();
82
+ if (trimmed === '') {
83
+ await clearPathConfig(getConfigFn, saveConfigFn, 'aifabrix-home');
84
+ return;
85
+ }
86
+ await setPathConfig(getConfigFn, saveConfigFn, 'aifabrix-home', trimmed, 'Home path must be a non-empty string');
66
87
  },
67
88
  async getAifabrixSecretsPath() {
68
89
  return getPathConfig(getConfigFn, 'aifabrix-secrets');
69
90
  },
70
91
  async setAifabrixSecretsPath(secretsPath) {
71
- await setPathConfig(getConfigFn, saveConfigFn, 'aifabrix-secrets', secretsPath, 'Secrets path is required and must be a string');
92
+ if (typeof secretsPath !== 'string') {
93
+ throw new Error('Secrets path is required and must be a string');
94
+ }
95
+ const trimmed = secretsPath.trim();
96
+ if (trimmed === '') {
97
+ await clearPathConfig(getConfigFn, saveConfigFn, 'aifabrix-secrets');
98
+ return;
99
+ }
100
+ await setPathConfig(getConfigFn, saveConfigFn, 'aifabrix-secrets', trimmed, 'Secrets path must be a non-empty string');
72
101
  }
73
102
  };
74
103
  }
75
104
 
105
+ /** Default env-config path when aifabrix-env-config is not set (builder schema). */
106
+ function getDefaultEnvConfigPath() {
107
+ return path.join(__dirname, '..', 'schema', 'env-config.yaml');
108
+ }
109
+
76
110
  function createEnvConfigPathFunctions(getConfigFn, saveConfigFn) {
77
111
  return {
78
112
  async getAifabrixEnvConfigPath() {
79
- return getPathConfig(getConfigFn, 'aifabrix-env-config');
113
+ const value = await getPathConfig(getConfigFn, 'aifabrix-env-config');
114
+ return value || getDefaultEnvConfigPath();
80
115
  },
81
116
  async setAifabrixEnvConfigPath(envConfigPath) {
82
- await setPathConfig(getConfigFn, saveConfigFn, 'aifabrix-env-config', envConfigPath, 'Env config path is required and must be a string');
117
+ if (typeof envConfigPath !== 'string') {
118
+ throw new Error('Env config path is required and must be a string');
119
+ }
120
+ const trimmed = envConfigPath.trim();
121
+ if (trimmed === '') {
122
+ await clearPathConfig(getConfigFn, saveConfigFn, 'aifabrix-env-config');
123
+ return;
124
+ }
125
+ await setPathConfig(getConfigFn, saveConfigFn, 'aifabrix-env-config', trimmed, 'Env config path must be a non-empty string');
83
126
  },
84
127
  async getAifabrixBuilderDir() {
85
128
  const envConfigPath = await getPathConfig(getConfigFn, 'aifabrix-env-config');
@@ -214,6 +257,7 @@ module.exports = {
214
257
  getPathConfig,
215
258
  setPathConfig,
216
259
  createPathConfigFunctions,
260
+ getDefaultEnvConfigPath,
217
261
  SETTINGS_RESPONSE_KEYS
218
262
  };
219
263
 
@@ -92,6 +92,17 @@ function kvPathInferred(segments) {
92
92
  return (namespace && pathVar) ? `kv://${namespace}/${pathVar}` : null;
93
93
  }
94
94
 
95
+ /**
96
+ * Returns the path segment used in kv://&lt;systemKey&gt;/&lt;segment&gt; for a given security key.
97
+ * Uses the same derivation as env key → path (securityKeyToVar + varSegmentsToCamelCase).
98
+ * @param {string} securityKey - Security key (e.g. 'apiKey', 'clientId', 'clientSecret')
99
+ * @returns {string} Canonical path segment (e.g. 'apiKey', 'clientId')
100
+ */
101
+ function getKvPathSegmentForSecurityKey(securityKey) {
102
+ if (!securityKey || typeof securityKey !== 'string') return '';
103
+ return varSegmentsToCamelCase([securityKeyToVar(securityKey)]);
104
+ }
105
+
95
106
  /**
96
107
  * Converts KV_* env key to kv:// path in format kv://&lt;system-key&gt;/&lt;variable&gt;.
97
108
  * System-key uses hyphens (e.g. microsoft-teams); variable is camelCase (e.g. clientId).
@@ -220,7 +231,10 @@ function buildItemsFromEnv(envFilePath, secrets, itemsByKey) {
220
231
  const fromEnv = collectKvEnvVarsAsSecretItems(envMap);
221
232
  for (const { key, value } of fromEnv) {
222
233
  const resolved = resolveKvValue(secrets, value);
223
- if (resolved !== null && resolved !== undefined && isValidKvPath(key)) itemsByKey.set(key, resolved);
234
+ // Skip placeholder: value that equals the kv path (e.g. from env.template) must not be pushed as the secret
235
+ if (resolved !== null && resolved !== undefined && isValidKvPath(key) && resolved.trim() !== key.trim()) {
236
+ itemsByKey.set(key, resolved);
237
+ }
224
238
  }
225
239
  } catch {
226
240
  // Best-effort: continue without .env items
@@ -349,6 +363,7 @@ module.exports = {
349
363
  collectKvRefsFromPayload,
350
364
  pushCredentialSecrets,
351
365
  kvEnvKeyToPath,
366
+ getKvPathSegmentForSecurityKey,
352
367
  systemKeyToKvPrefix,
353
368
  securityKeyToVar,
354
369
  isValidKvPath,
@@ -270,9 +270,13 @@ function calculateDockerPublicPorts(result, devIdNum, schemaBaseVars = {}) {
270
270
  // Match any variable ending with _PORT (e.g., MISO_PORT, KEYCLOAK_PORT, DB_PORT)
271
271
  if (/_PORT$/.test(key) && !/_PUBLIC_PORT$/.test(key)) {
272
272
  const publicPortKey = key.replace(/_PORT$/, '_PUBLIC_PORT');
273
- // Use schema port when available so PUBLIC_PORT is canonical (e.g. 8082), not overridden (e.g. 8080)
274
- const schemaPort = schemaBaseVars[key];
275
- const sourceVal = schemaPort !== undefined && schemaPort !== null ? schemaPort : value;
273
+ // Prefer schema *_PUBLIC_PORT (e.g. KEYCLOAK_PUBLIC_PORT: 8082) so public port is canonical;
274
+ // fall back to schema *_PORT (e.g. KEYCLOAK_PORT: 8080) then merged value
275
+ const schemaPublic = schemaBaseVars[publicPortKey];
276
+ const schemaInternal = schemaBaseVars[key];
277
+ const sourceVal = schemaPublic !== undefined && schemaPublic !== null
278
+ ? schemaPublic
279
+ : (schemaInternal !== undefined && schemaInternal !== null ? schemaInternal : value);
276
280
  let portVal;
277
281
  if (typeof sourceVal === 'string') {
278
282
  portVal = parseInt(sourceVal, 10);
@@ -19,6 +19,8 @@ const PATTERN_DESCRIPTIONS = {
19
19
  '^[a-z-]+$': 'lowercase letters and hyphens only',
20
20
  '^[A-Z_][A-Z0-9_]*$': 'uppercase letters, numbers, and underscores (must start with letter or underscore)',
21
21
  '^[a-zA-Z0-9_-]+$': 'letters, numbers, hyphens, and underscores only',
22
+ '^[a-zA-Z0-9_]+$': 'letters, numbers, and underscores only',
23
+ '^[a-zA-Z0-9_.]+$': 'letters, numbers, underscores, and dots only',
22
24
  '^(http|https)://.*$': 'valid HTTP or HTTPS URL',
23
25
  '^/[a-z0-9/-]*$': 'URL path starting with / (lowercase letters, numbers, hyphens, slashes)'
24
26
  };
@@ -132,6 +134,35 @@ function createKeywordFormatters(field, error) {
132
134
  * @param {Object} error - Raw validation error from Ajv
133
135
  * @returns {string} Formatted error message
134
136
  */
137
+ /**
138
+ * Formats oneOf/anyOf validation errors with actionable message
139
+ * @param {string} field - Field name
140
+ * @param {Object} error - AJV error (keyword oneOf or anyOf)
141
+ * @returns {string} Formatted error message
142
+ */
143
+ function formatOneOfAnyOfError(field, error) {
144
+ const instancePath = (error.instancePath || '').replace(/^\//, '');
145
+ if (instancePath === 'capabilities') {
146
+ return `${field}: must be either an array of operation names (e.g. ["list","get"]) or an object with boolean flags (e.g. { "list": true }).`;
147
+ }
148
+ return `${field}: value does not match any allowed shape. Check type and required fields.`;
149
+ }
150
+
151
+ /**
152
+ * Formats const validation errors
153
+ * @param {string} field - Field name
154
+ * @param {Object} error - AJV error (keyword const)
155
+ * @returns {string} Formatted error message
156
+ */
157
+ function formatConstError(field, error) {
158
+ const allowed = error.params?.allowedValue;
159
+ if (allowed !== undefined) {
160
+ const display = typeof allowed === 'string' ? `"${allowed}"` : String(allowed);
161
+ return `${field}: must be exactly ${display}`;
162
+ }
163
+ return `${field}: invalid value (constraint violation)`;
164
+ }
165
+
135
166
  function formatSingleError(error) {
136
167
  const field = getFieldName(error);
137
168
 
@@ -142,6 +173,12 @@ function formatSingleError(error) {
142
173
  if (error.keyword === 'additionalProperties') {
143
174
  return formatAdditionalPropertiesError(field, error);
144
175
  }
176
+ if (error.keyword === 'oneOf' || error.keyword === 'anyOf') {
177
+ return formatOneOfAnyOfError(field, error);
178
+ }
179
+ if (error.keyword === 'const') {
180
+ return formatConstError(field, error);
181
+ }
145
182
 
146
183
  // Use object lookup for keyword-specific messages
147
184
  const formatters = createKeywordFormatters(field, error);
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Builds Handlebars context and generates env.template content for external systems.
3
+ * Single source for create, download, split, and repair so env.template structure is consistent.
4
+ *
5
+ * @fileoverview External system env.template generation
6
+ * @author AI Fabrix Team
7
+ * @version 2.0.0
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const path = require('path');
13
+ const fs = require('fs');
14
+ const Handlebars = require('handlebars');
15
+ const { systemKeyToKvPrefix, kvEnvKeyToPath, securityKeyToVar } = require('./credential-secrets-env');
16
+
17
+ /**
18
+ * Builds hint string from portalInput (options → enum, validation → min-max or pattern).
19
+ * @param {Object} portalInput - Portal input config (label, options, validation)
20
+ * @returns {string} Hint suffix for comment
21
+ */
22
+ function buildPortalInputHint(portalInput) {
23
+ if (!portalInput || typeof portalInput !== 'object') return '';
24
+ const parts = [];
25
+ if (Array.isArray(portalInput.options) && portalInput.options.length > 0) {
26
+ parts.push(`enum ${portalInput.options.join(',')}`);
27
+ }
28
+ const v = portalInput.validation;
29
+ if (v && typeof v === 'object') {
30
+ if (typeof v.minLength === 'number' || typeof v.maxLength === 'number') {
31
+ parts.push('min-max');
32
+ } else if (typeof v.pattern === 'string' && v.pattern) {
33
+ parts.push('pattern');
34
+ }
35
+ }
36
+ return parts.length ? ` - ${parts.join(', ')}` : '';
37
+ }
38
+
39
+ /** Fallback security keys by auth method when authentication.security is absent. */
40
+ const FALLBACK_SECURITY_BY_AUTH = {
41
+ oauth2: ['clientId', 'clientSecret'],
42
+ oauth: ['clientId', 'clientSecret'],
43
+ aad: ['clientId', 'clientSecret'],
44
+ apikey: ['apiKey'],
45
+ apiKey: ['apiKey'],
46
+ basic: ['username', 'password'],
47
+ queryParam: ['paramValue'],
48
+ oidc: [],
49
+ hmac: ['signingSecret'],
50
+ bearer: ['bearerToken'],
51
+ token: ['bearerToken'],
52
+ none: []
53
+ };
54
+
55
+ /**
56
+ * Builds authSecureVars array from system authentication.security (or fallback by auth type).
57
+ * @param {Object} system - System object with key and authentication
58
+ * @returns {Array<{name: string, value: string}>}
59
+ */
60
+ function buildAuthSecureVarsFromSystem(system) {
61
+ const authSecureVars = [];
62
+ const systemKey = system?.key || 'external-system';
63
+ const prefix = systemKeyToKvPrefix(systemKey);
64
+ if (!prefix) return authSecureVars;
65
+ const security = system?.authentication?.security || system?.auth?.security;
66
+ const authMethod = (system?.authentication?.method || system?.authentication?.type ||
67
+ system?.auth?.method || system?.auth?.type || 'apikey').toLowerCase();
68
+ if (security && typeof security === 'object' && Object.keys(security).length > 0) {
69
+ for (const key of Object.keys(security)) {
70
+ const envName = `KV_${prefix}_${securityKeyToVar(key)}`;
71
+ const pathVal = kvEnvKeyToPath(envName, systemKey);
72
+ authSecureVars.push({ name: envName, value: pathVal || `kv://${systemKey}/${key}` });
73
+ }
74
+ } else {
75
+ const keys = FALLBACK_SECURITY_BY_AUTH[authMethod] || FALLBACK_SECURITY_BY_AUTH.apikey;
76
+ for (const key of keys) {
77
+ authSecureVars.push({
78
+ name: `KV_${prefix}_${securityKeyToVar(key)}`,
79
+ value: `kv://${systemKey}/${key}`
80
+ });
81
+ }
82
+ }
83
+ return authSecureVars;
84
+ }
85
+
86
+ /**
87
+ * Builds configuration array with name, value, comment from system.configuration.
88
+ * @param {Object} system - System object with configuration array
89
+ * @returns {Array<{name: string, value: string, comment: string}>}
90
+ */
91
+ function buildConfigurationEntries(system) {
92
+ const configuration = [];
93
+ const configList = Array.isArray(system?.configuration) ? system.configuration : [];
94
+ for (const entry of configList) {
95
+ if (!entry || !entry.name) continue;
96
+ const label = entry.portalInput?.label || entry.name;
97
+ const hint = buildPortalInputHint(entry.portalInput || {});
98
+ let value = entry.value !== undefined && entry.value !== null ? String(entry.value) : '';
99
+ if (entry.location === 'keyvault' && value && !value.startsWith('kv://')) value = `kv://${value}`;
100
+ configuration.push({ name: entry.name, value, comment: `${label}${hint}` });
101
+ }
102
+ return configuration;
103
+ }
104
+
105
+ /**
106
+ * Builds template context from system object for env.template.hbs.
107
+ * @param {Object} system - Full system object (e.g. deployment.system or parsed system file)
108
+ * @returns {{ authMethod: string, authSecureVars: Array<{name: string, value: string}>, authNonSecureVarNames: string[], configuration: Array<{name: string, value: string, comment: string}> }}
109
+ */
110
+ function buildExternalEnvTemplateContext(system) {
111
+ const authMethod = (system?.authentication?.method ||
112
+ system?.authentication?.type ||
113
+ system?.auth?.method ||
114
+ system?.auth?.type ||
115
+ 'apikey').toLowerCase();
116
+ const authSecureVars = buildAuthSecureVarsFromSystem(system);
117
+ const authVars = system?.authentication?.variables || system?.auth?.variables || {};
118
+ const authNonSecureVarNames = Object.keys(authVars);
119
+ const configuration = buildConfigurationEntries(system);
120
+ return {
121
+ authMethod,
122
+ authSecureVars,
123
+ authNonSecureVarNames,
124
+ configuration
125
+ };
126
+ }
127
+
128
+ /** Inline fallback when env.template.hbs is missing or unreadable (e.g. CI path or bundled). */
129
+ const DEFAULT_ENV_TEMPLATE_HBS = `# Environment variables for external system integration
130
+ # Use kv:// (or aifabrix secret set) for sensitive values; plain values for non-sensitive configuration.
131
+ #
132
+
133
+ {{#if authMethod}}
134
+ # Authentication
135
+ # Type: {{authMethod}}
136
+ {{#each authSecureVars}}
137
+ {{name}}={{value}}
138
+ {{/each}}
139
+ {{#if authNonSecureVarNames}}
140
+ # Non-secure (e.g. URLs): {{#each authNonSecureVarNames}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}
141
+ {{/if}}
142
+
143
+ {{/if}}
144
+ {{#if configuration.length}}
145
+ # Configuration
146
+ {{#each configuration}}
147
+ # {{comment}}
148
+ {{name}}={{value}}
149
+ {{/each}}
150
+ {{/if}}
151
+ `;
152
+
153
+ /**
154
+ * Generates env.template content from system using the Handlebars template.
155
+ * @param {Object} system - Full system object (e.g. deployment.system or parsed system file)
156
+ * @returns {string} Rendered env.template content
157
+ */
158
+ function generateExternalEnvTemplateContent(system) {
159
+ if (!system || typeof system !== 'object') {
160
+ return '# Environment variables for external system integration\n# Use kv:// (or aifabrix secret set) for sensitive values.\n\n';
161
+ }
162
+ let templateContent;
163
+ try {
164
+ const templatePath = path.join(__dirname, '..', '..', 'templates', 'external-system', 'env.template.hbs');
165
+ templateContent = fs.readFileSync(templatePath, 'utf8');
166
+ } catch (_) {
167
+ templateContent = undefined;
168
+ }
169
+ if (typeof templateContent !== 'string' || !templateContent.trim()) {
170
+ templateContent = DEFAULT_ENV_TEMPLATE_HBS;
171
+ }
172
+ const template = Handlebars.compile(templateContent);
173
+ const context = buildExternalEnvTemplateContext(system);
174
+ return template(context);
175
+ }
176
+
177
+ module.exports = {
178
+ buildExternalEnvTemplateContext,
179
+ generateExternalEnvTemplateContent
180
+ };