@aifabrix/builder 2.42.1 → 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 (117) 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/app/register.js +3 -1
  38. package/lib/app/rotate-secret.js +3 -0
  39. package/lib/cli/setup-app.js +2 -2
  40. package/lib/cli/setup-auth.js +19 -11
  41. package/lib/cli/setup-dev.js +62 -32
  42. package/lib/cli/setup-environment.js +6 -21
  43. package/lib/cli/setup-infra.js +13 -0
  44. package/lib/cli/setup-secrets.js +45 -6
  45. package/lib/cli/setup-service-user.js +146 -20
  46. package/lib/cli/setup-utility.js +12 -0
  47. package/lib/commands/auth-config.js +4 -8
  48. package/lib/commands/datasource.js +46 -1
  49. package/lib/commands/dev-init.js +1 -1
  50. package/lib/commands/repair-env-template.js +14 -8
  51. package/lib/commands/repair-rbac.js +25 -19
  52. package/lib/commands/repair.js +96 -30
  53. package/lib/commands/secrets-remove.js +1 -1
  54. package/lib/commands/secrets-validate.js +17 -4
  55. package/lib/commands/service-user.js +231 -2
  56. package/lib/commands/up-common.js +25 -0
  57. package/lib/commands/up-dataplane.js +2 -2
  58. package/lib/core/admin-secrets.js +2 -0
  59. package/lib/core/config.js +7 -5
  60. package/lib/core/ensure-encryption-key.js +1 -3
  61. package/lib/core/secrets.js +32 -9
  62. package/lib/core/templates.js +1 -1
  63. package/lib/datasource/abac-validator.js +157 -0
  64. package/lib/datasource/field-reference-validator.js +74 -36
  65. package/lib/datasource/log-viewer.js +221 -0
  66. package/lib/datasource/resolve-app.js +109 -0
  67. package/lib/datasource/test-e2e.js +11 -20
  68. package/lib/datasource/test-integration.js +42 -22
  69. package/lib/datasource/validate.js +5 -2
  70. package/lib/external-system/generator.js +12 -8
  71. package/lib/external-system/test-system-level.js +1 -1
  72. package/lib/generator/external-controller-manifest.js +3 -3
  73. package/lib/generator/external.js +7 -7
  74. package/lib/generator/helpers.js +13 -9
  75. package/lib/generator/index.js +4 -4
  76. package/lib/generator/split.js +45 -10
  77. package/lib/generator/wizard.js +9 -6
  78. package/lib/infrastructure/helpers.js +50 -35
  79. package/lib/infrastructure/index.js +39 -23
  80. package/lib/schema/env-config.yaml +19 -2
  81. package/lib/schema/external-datasource.schema.json +11 -1
  82. package/lib/utils/app-config-resolver.js +23 -1
  83. package/lib/utils/config-paths.js +48 -4
  84. package/lib/utils/credential-secrets-env.js +16 -1
  85. package/lib/utils/env-map.js +7 -3
  86. package/lib/utils/error-formatter.js +37 -0
  87. package/lib/utils/external-env-template.js +180 -0
  88. package/lib/utils/external-system-display.js +43 -0
  89. package/lib/utils/external-system-validators.js +2 -2
  90. package/lib/utils/help-builder.js +3 -5
  91. package/lib/utils/local-secrets.js +26 -3
  92. package/lib/utils/paths.js +2 -1
  93. package/lib/utils/secrets-generator.js +2 -2
  94. package/lib/utils/secrets-utils.js +4 -0
  95. package/lib/utils/secure-file-permissions.js +91 -0
  96. package/lib/utils/token-manager.js +36 -3
  97. package/lib/utils/yaml-preserve.js +59 -1
  98. package/lib/validation/env-template-auth.js +50 -2
  99. package/lib/validation/external-manifest-validator.js +8 -0
  100. package/lib/validation/validate.js +8 -0
  101. package/lib/validation/validator.js +10 -13
  102. package/package.json +5 -1
  103. package/templates/applications/dataplane/env.template +5 -1
  104. package/templates/applications/miso-controller/application.yaml +1 -1
  105. package/templates/applications/miso-controller/env.template +13 -2
  106. package/templates/external-system/env.template.hbs +22 -0
  107. package/integration/hubspot/README.md +0 -102
  108. package/integration/hubspot/env.template +0 -4
  109. package/integration/hubspot/test-artifacts/wizard-invalid-missing-source.yaml +0 -2
  110. package/integration/hubspot/test-artifacts/wizard-valid-for-dimension-key-test.yaml +0 -5
  111. package/integration/hubspot/test-artifacts/wizard-valid-for-dimension-path-test.yaml +0 -5
  112. /package/integration/{hubspot → hubspot-test}/companies.json +0 -0
  113. /package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-app-name.yaml +0 -0
  114. /package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-missing-app.yaml +0 -0
  115. /package/integration/{hubspot → hubspot-test}/test-dataplane-down-helpers.js +0 -0
  116. /package/integration/{hubspot → hubspot-test}/test-dataplane-down-tests.js +0 -0
  117. /package/integration/{hubspot → hubspot-test}/test-dataplane-down.js +0 -0
@@ -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
@@ -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
+ };
@@ -289,6 +289,20 @@ function displayE2EResults(data, verbose = false) {
289
289
  logger.log(` ${ok ? chalk.green('✓') : chalk.red('✗')} ${name}`);
290
290
  if (!ok && (step.error || step.message)) logger.log(chalk.red(` ${step.error || step.message}`));
291
291
  if (verbose && step.message && ok) logger.log(chalk.gray(` ${step.message}`));
292
+ if (verbose && ok && (name === 'sync' || step.step === 'sync') && step.evidence && step.evidence.jobs) {
293
+ formatSyncStepEvidence(step.evidence.jobs);
294
+ }
295
+ }
296
+ if (verbose && data.auditLog && Array.isArray(data.auditLog) && data.auditLog.length > 0) {
297
+ const n = data.auditLog.length;
298
+ const first = data.auditLog[0];
299
+ const execId = (first && (first.executionId || first.id || first.traceId)) ? String(first.executionId || first.id || first.traceId) : null;
300
+ if (execId) {
301
+ const short = execId.length > 10 ? `${execId.slice(0, 8)}…` : execId;
302
+ logger.log(chalk.gray(` CIP execution trace(s): ${n} (executionId: ${short})`));
303
+ } else {
304
+ logger.log(chalk.gray(` CIP execution trace(s): ${n}`));
305
+ }
292
306
  }
293
307
  if (isRunning) {
294
308
  return;
@@ -297,6 +311,35 @@ function displayE2EResults(data, verbose = false) {
297
311
  logger.log(allPassed ? chalk.green('\n✅ E2E test passed!') : chalk.red('\n❌ E2E test failed'));
298
312
  }
299
313
 
314
+ /**
315
+ * Log sync step job evidence (record counts) in verbose E2E output
316
+ * @param {Object[]} jobs - evidence.jobs from sync step
317
+ */
318
+ function formatSyncStepEvidence(jobs) {
319
+ for (const job of jobs) {
320
+ const rec = job.recordsProcessed ?? job.totalProcessed;
321
+ const total = job.totalRecords ?? (job.audit && job.audit.totalProcessed);
322
+ const parts = [];
323
+ if (rec !== undefined && rec !== null) parts.push(`${rec} processed`);
324
+ if (total !== undefined && total !== null) parts.push(`total: ${total}`);
325
+ const audit = job.audit || {};
326
+ const ins = audit.inserted ?? job.insertedCount;
327
+ const upd = audit.updated ?? job.updatedCount;
328
+ const del = audit.deleted ?? job.deletedCount;
329
+ const tot = audit.totalProcessed ?? total;
330
+ if (ins !== undefined || upd !== undefined || del !== undefined || tot !== undefined) {
331
+ const a = [`inserted: ${ins ?? 0}`, `updated: ${upd ?? 0}`, `deleted: ${del ?? 0}`];
332
+ if (tot !== undefined) a.push(`totalProcessed: ${tot}`);
333
+ parts.push(`(${a.join(', ')})`);
334
+ }
335
+ if (job.skippedCount !== undefined) parts.push(`skipped: ${job.skippedCount}`);
336
+ if (job.rejectedByQualityCount !== undefined) parts.push(`rejectedByQuality: ${job.rejectedByQualityCount}`);
337
+ if (parts.length > 0) {
338
+ logger.log(chalk.gray(` Managed records: ${parts.join(' ')}`));
339
+ }
340
+ }
341
+ }
342
+
300
343
  module.exports = {
301
344
  displayTestResults,
302
345
  displayIntegrationTestResults,
@@ -139,11 +139,11 @@ function validateDimensions(dimensions, results) {
139
139
  // Validate dimension keys and values
140
140
  for (const [dimensionKey, attributePath] of Object.entries(dimensions)) {
141
141
  if (!/^[a-zA-Z0-9_]+$/.test(dimensionKey)) {
142
- results.errors.push(`Invalid dimension key '${dimensionKey}': must match pattern ^[a-zA-Z0-9_]+$`);
142
+ results.errors.push(`Invalid dimension key '${dimensionKey}': dimension key must contain only letters, numbers, and underscores`);
143
143
  results.valid = false;
144
144
  }
145
145
  if (typeof attributePath !== 'string' || !/^[a-zA-Z0-9_.]+$/.test(attributePath)) {
146
- results.errors.push(`Invalid attribute path '${attributePath}' for dimension '${dimensionKey}': must match pattern ^[a-zA-Z0-9_.]+$`);
146
+ results.errors.push(`Invalid attribute path '${attributePath}' for dimension '${dimensionKey}': attribute path must contain only letters, numbers, underscores, and dots`);
147
147
  results.valid = false;
148
148
  }
149
149
  }
@@ -46,9 +46,7 @@ const CATEGORIES = [
46
46
  { name: 'build', term: 'build <app>' },
47
47
  { name: 'run', term: 'run <app>' },
48
48
  { name: 'shell', term: 'shell <app>' },
49
- { name: 'test', term: 'test <app>' },
50
49
  { name: 'install', term: 'install <app>' },
51
- { name: 'test-e2e', term: 'test-e2e <app>' },
52
50
  { name: 'lint', term: 'lint <app>' },
53
51
  { name: 'logs', term: 'logs <app>' },
54
52
  { name: 'stop', term: 'stop <app>' },
@@ -65,15 +63,13 @@ const CATEGORIES = [
65
63
  {
66
64
  name: 'Environments',
67
65
  commands: [
68
- { name: 'environment' },
69
66
  { name: 'env' }
70
67
  ]
71
68
  },
72
69
  {
73
- name: 'Application & Datasource Management',
70
+ name: 'Application & Management',
74
71
  commands: [
75
72
  { name: 'app' },
76
- { name: 'datasource' },
77
73
  { name: 'credential' },
78
74
  { name: 'deployment' },
79
75
  { name: 'service-user' }
@@ -98,7 +94,9 @@ const CATEGORIES = [
98
94
  { name: 'upload', term: 'upload <system-key>' },
99
95
  { name: 'delete', term: 'delete <system-key>' },
100
96
  { name: 'repair', term: 'repair <app>' },
97
+ { name: 'datasource' },
101
98
  { name: 'test', term: 'test <app>' },
99
+ { name: 'test-e2e', term: 'test-e2e <app>' },
102
100
  { name: 'test-integration', term: 'test-integration <app>' }
103
101
  ]
104
102
  },
@@ -15,10 +15,31 @@ const logger = require('../utils/logger');
15
15
  const pathsUtil = require('./paths');
16
16
  const { mergeSecretsIntoFile } = require('./secrets-generator');
17
17
 
18
+ /** Bootstrap key name; never encrypt this key's value when writing (key is stored in config). */
19
+ const ENCRYPTION_KEY_VAULT = 'secrets-encryptionKeyVault';
20
+
21
+ /**
22
+ * Resolves value to write: encrypted (secure://) when encryption key is set and key is not the bootstrap key.
23
+ * @async
24
+ * @param {string} key - Secret key name
25
+ * @param {string} value - Secret value
26
+ * @returns {Promise<string>} Value to write (plaintext or secure://...)
27
+ */
28
+ async function resolveValueForWrite(key, value) {
29
+ const config = require('../core/config');
30
+ const encryptionKey = await config.getSecretsEncryptionKey();
31
+ if (!encryptionKey || key === ENCRYPTION_KEY_VAULT) {
32
+ return typeof value === 'string' ? value : String(value);
33
+ }
34
+ const { encryptSecret } = require('./secrets-encryption');
35
+ return encryptSecret(typeof value === 'string' ? value : String(value), encryptionKey);
36
+ }
37
+
18
38
  /**
19
39
  * Saves a secret to ~/.aifabrix/secrets.local.yaml
20
40
  * Uses paths.getAifabrixHome() to respect config.yaml aifabrix-home override
21
- * Merges the key into the file (updates in place if key already exists, e.g. after rotate-secret)
41
+ * Merges the key into the file (updates in place if key already exists, e.g. after rotate-secret).
42
+ * Encrypts the value when a secrets-encryption key is configured (except for the bootstrap key).
22
43
  *
23
44
  * @async
24
45
  * @function saveLocalSecret
@@ -39,8 +60,9 @@ async function saveLocalSecret(key, value) {
39
60
  throw new Error('Secret value is required');
40
61
  }
41
62
 
63
+ const valueToWrite = await resolveValueForWrite(key, value);
42
64
  const secretsPath = path.join(pathsUtil.getAifabrixHome(), 'secrets.local.yaml');
43
- mergeSecretsIntoFile(secretsPath, { [key]: value });
65
+ mergeSecretsIntoFile(secretsPath, { [key]: valueToWrite });
44
66
  }
45
67
 
46
68
  /**
@@ -121,8 +143,9 @@ function _loadExistingSecrets(resolvedPath) {
121
143
  async function saveSecret(key, value, secretsPath) {
122
144
  validateSaveSecretParams(key, value, secretsPath);
123
145
 
146
+ const valueToWrite = await resolveValueForWrite(key, value);
124
147
  const resolvedPath = resolveAndPrepareSecretsPath(secretsPath);
125
- mergeSecretsIntoFile(resolvedPath, { [key]: value });
148
+ mergeSecretsIntoFile(resolvedPath, { [key]: valueToWrite });
126
149
  }
127
150
 
128
151
  /**
@@ -430,7 +430,7 @@ function getDeployJsonPath(appName, appType, preferNew = false) {
430
430
  // If neither exists, return new naming (for generation)
431
431
  return newPath;
432
432
  }
433
- const { resolveApplicationConfigPath } = require('./app-config-resolver');
433
+ const { resolveApplicationConfigPath, resolveRbacPath } = require('./app-config-resolver');
434
434
  const { loadConfigFile } = require('./config-format');
435
435
  /**
436
436
  * Checks if app type is external from variables object
@@ -562,6 +562,7 @@ module.exports = {
562
562
  resolveBuildContext,
563
563
  getDeployJsonPath,
564
564
  resolveApplicationConfigPath,
565
+ resolveRbacPath,
565
566
  detectAppType,
566
567
  getResolveAppPath,
567
568
  resolveIntegrationAppKeyFromCwd,