@aifabrix/builder 2.41.0 → 2.42.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 (138) hide show
  1. package/.cursor/rules/docs-rules.mdc +30 -0
  2. package/README.md +1 -1
  3. package/integration/hubspot/README.md +8 -4
  4. package/integration/hubspot/application.json +54 -0
  5. package/integration/hubspot/create-hubspot.js +9 -136
  6. package/integration/hubspot/env.template +3 -4
  7. package/integration/hubspot/hubspot-datasource-company.json +343 -5
  8. package/integration/hubspot/hubspot-datasource-contact.json +413 -5
  9. package/integration/hubspot/hubspot-datasource-deal.json +341 -4
  10. package/integration/hubspot/hubspot-datasource-users.json +116 -0
  11. package/integration/hubspot/hubspot-deploy.json +1250 -108
  12. package/integration/hubspot/hubspot-system.json +15 -32
  13. package/integration/hubspot/test-dataplane-down-tests.js +17 -16
  14. package/integration/hubspot/test-dataplane-down.js +2 -2
  15. package/jest.config.manual.js +2 -1
  16. package/lib/api/external-test.api.js +111 -0
  17. package/lib/api/index.js +42 -19
  18. package/lib/api/pipeline.api.js +66 -120
  19. package/lib/api/types/pipeline.types.js +37 -0
  20. package/lib/api/wizard-platform.api.js +61 -0
  21. package/lib/api/wizard.api.js +34 -1
  22. package/lib/app/config.js +23 -11
  23. package/lib/app/index.js +3 -1
  24. package/lib/app/prompts.js +44 -29
  25. package/lib/app/readme.js +8 -3
  26. package/lib/app/run-env-compose.js +64 -1
  27. package/lib/app/run-helpers.js +1 -1
  28. package/lib/app/show-display.js +1 -1
  29. package/lib/cli/setup-app.js +42 -11
  30. package/lib/cli/setup-credential-deployment.js +31 -6
  31. package/lib/cli/setup-dev.js +27 -0
  32. package/lib/cli/setup-environment.js +12 -4
  33. package/lib/cli/setup-external-system.js +19 -4
  34. package/lib/cli/setup-infra.js +54 -14
  35. package/lib/cli/setup-utility.js +117 -21
  36. package/lib/commands/credential-env.js +162 -0
  37. package/lib/commands/credential-list.js +17 -22
  38. package/lib/commands/credential-push.js +96 -0
  39. package/lib/commands/datasource.js +77 -6
  40. package/lib/commands/dev-init.js +39 -1
  41. package/lib/commands/repair-auth-config.js +99 -0
  42. package/lib/commands/repair-datasource-keys.js +208 -0
  43. package/lib/commands/repair-datasource.js +235 -0
  44. package/lib/commands/repair-env-template.js +348 -0
  45. package/lib/commands/repair-internal.js +85 -0
  46. package/lib/commands/repair-rbac.js +158 -0
  47. package/lib/commands/repair.js +507 -0
  48. package/lib/commands/test-e2e-external.js +165 -0
  49. package/lib/commands/upload.js +71 -40
  50. package/lib/commands/wizard-core-helpers.js +226 -4
  51. package/lib/commands/wizard-core.js +67 -29
  52. package/lib/commands/wizard-dataplane.js +1 -1
  53. package/lib/commands/wizard-entity-selection.js +43 -0
  54. package/lib/commands/wizard-headless.js +44 -5
  55. package/lib/commands/wizard-helpers.js +7 -3
  56. package/lib/commands/wizard.js +86 -64
  57. package/lib/core/config.js +7 -1
  58. package/lib/core/secrets.js +33 -12
  59. package/lib/datasource/deploy.js +12 -3
  60. package/lib/datasource/test-e2e.js +219 -0
  61. package/lib/datasource/test-integration.js +154 -0
  62. package/lib/deployment/deployer.js +7 -5
  63. package/lib/external-system/download.js +182 -204
  64. package/lib/external-system/generator.js +204 -56
  65. package/lib/external-system/test-execution.js +2 -1
  66. package/lib/external-system/test-system-level.js +73 -0
  67. package/lib/external-system/test.js +51 -18
  68. package/lib/generator/external-controller-manifest.js +29 -2
  69. package/lib/generator/external-schema-utils.js +1 -1
  70. package/lib/generator/external.js +10 -3
  71. package/lib/generator/index.js +4 -1
  72. package/lib/generator/split-readme.js +1 -0
  73. package/lib/generator/split-variables.js +7 -1
  74. package/lib/generator/split.js +194 -54
  75. package/lib/generator/wizard-prompts-secondary.js +294 -0
  76. package/lib/generator/wizard-prompts.js +105 -106
  77. package/lib/generator/wizard-readme.js +88 -0
  78. package/lib/generator/wizard.js +147 -158
  79. package/lib/infrastructure/compose.js +11 -1
  80. package/lib/infrastructure/index.js +11 -3
  81. package/lib/infrastructure/services.js +22 -11
  82. package/lib/schema/application-schema.json +8 -5
  83. package/lib/schema/external-datasource.schema.json +49 -26
  84. package/lib/schema/external-system.schema.json +82 -6
  85. package/lib/schema/wizard-config.schema.json +16 -0
  86. package/lib/utils/api.js +38 -10
  87. package/lib/utils/auth-headers.js +8 -7
  88. package/lib/utils/compose-generator.js +1 -1
  89. package/lib/utils/compose-handlebars-helpers.js +11 -0
  90. package/lib/utils/config-format-preference.js +51 -0
  91. package/lib/utils/config-format.js +36 -0
  92. package/lib/utils/configuration-env-resolver.js +179 -0
  93. package/lib/utils/credential-display.js +83 -0
  94. package/lib/utils/credential-secrets-env.js +115 -25
  95. package/lib/utils/dataplane-pipeline-warning.js +28 -0
  96. package/lib/utils/deployment-validation-helpers.js +4 -4
  97. package/lib/utils/dev-ca-install.js +139 -0
  98. package/lib/utils/env-copy.js +23 -3
  99. package/lib/utils/error-formatters/http-status-errors.js +0 -1
  100. package/lib/utils/error-formatters/permission-errors.js +0 -1
  101. package/lib/utils/error-formatters/validation-errors.js +0 -1
  102. package/lib/utils/external-readme.js +56 -29
  103. package/lib/utils/external-system-display.js +59 -1
  104. package/lib/utils/external-system-test-helpers.js +21 -8
  105. package/lib/utils/external-system-validators.js +3 -0
  106. package/lib/utils/file-upload.js +20 -50
  107. package/lib/utils/help-builder.js +1 -0
  108. package/lib/utils/infra-status.js +50 -44
  109. package/lib/utils/local-secrets.js +5 -5
  110. package/lib/utils/paths.js +85 -4
  111. package/lib/utils/secrets-canonical.js +93 -0
  112. package/lib/utils/secrets-generator.js +20 -0
  113. package/lib/utils/secrets-helpers.js +75 -89
  114. package/lib/utils/test-log-writer.js +56 -0
  115. package/lib/utils/token-manager.js +24 -32
  116. package/lib/validation/env-template-auth.js +157 -0
  117. package/lib/validation/env-template-kv.js +41 -0
  118. package/lib/validation/external-manifest-validator.js +25 -0
  119. package/lib/validation/external-system-auth-rules.js +86 -0
  120. package/lib/validation/validate-batch.js +149 -0
  121. package/lib/validation/validate-datasource-keys-api.js +33 -0
  122. package/lib/validation/validate-display.js +94 -16
  123. package/lib/validation/validate.js +25 -12
  124. package/lib/validation/validator.js +7 -9
  125. package/lib/validation/wizard-datasource-validation.js +50 -0
  126. package/package.json +7 -2
  127. package/templates/applications/dataplane/application.yaml +1 -1
  128. package/templates/applications/dataplane/env.template +5 -5
  129. package/templates/applications/dataplane/rbac.yaml +2 -2
  130. package/templates/applications/miso-controller/env.template +1 -1
  131. package/templates/external-system/README.md.hbs +65 -25
  132. package/templates/external-system/deploy.js.hbs +4 -2
  133. package/templates/external-system/external-datasource.yaml.hbs +217 -0
  134. package/templates/external-system/external-system.json.hbs +1 -18
  135. package/templates/infra/compose.yaml.hbs +6 -0
  136. package/templates/python/docker-compose.hbs +4 -4
  137. package/templates/typescript/docker-compose.hbs +4 -4
  138. package/integration/hubspot/application.yaml +0 -37
@@ -1,13 +1,11 @@
1
1
  /**
2
2
  * Path Utilities for AI Fabrix Builder
3
- *
4
- * Centralized helpers for resolving filesystem locations with support for
5
- * AIFABRIX_HOME override. Defaults to ~/.aifabrix when not specified.
6
- *
3
+ * Centralized helpers for resolving filesystem locations with AIFABRIX_HOME override.
7
4
  * @fileoverview Path resolution utilities with environment overrides
8
5
  * @author AI Fabrix Team
9
6
  * @version 2.0.0
10
7
  */
8
+ /* eslint-disable max-lines -- Central path resolution; resolveIntegrationAppKeyFromCwd for datasource commands */
11
9
 
12
10
  'use strict';
13
11
 
@@ -279,6 +277,76 @@ function getIntegrationBuilderBaseDir() {
279
277
  return cwd;
280
278
  }
281
279
 
280
+ /**
281
+ * Returns the integration root directory (used for listing apps).
282
+ * @returns {string} Absolute path to integration/ directory
283
+ */
284
+ function getIntegrationRoot() {
285
+ return path.join(getIntegrationBuilderBaseDir(), 'integration');
286
+ }
287
+
288
+ /**
289
+ * Returns the builder root directory. Uses AIFABRIX_BUILDER_DIR when set, else project/cwd + builder.
290
+ * @returns {string} Absolute path to builder/ directory
291
+ */
292
+ function getBuilderRoot() {
293
+ const envDir = process.env.AIFABRIX_BUILDER_DIR && typeof process.env.AIFABRIX_BUILDER_DIR === 'string'
294
+ ? process.env.AIFABRIX_BUILDER_DIR.trim()
295
+ : null;
296
+ if (envDir) {
297
+ return path.resolve(envDir);
298
+ }
299
+ return path.join(getIntegrationBuilderBaseDir(), 'builder');
300
+ }
301
+
302
+ /**
303
+ * Lists app names (directories) under integration root. Excludes dot-prefixed entries.
304
+ * Returns [] if root does not exist.
305
+ * @returns {string[]} Sorted list of app directory names
306
+ */
307
+ function listIntegrationAppNames() {
308
+ const root = getIntegrationRoot();
309
+ if (!fs.existsSync(root)) {
310
+ return [];
311
+ }
312
+ const stat = fs.statSync(root);
313
+ if (!stat || typeof stat.isDirectory !== 'function' || !stat.isDirectory()) {
314
+ return [];
315
+ }
316
+ const entries = fs.readdirSync(root);
317
+ return entries
318
+ .filter(name => !name.startsWith('.'))
319
+ .filter(name => {
320
+ const fullPath = path.join(root, name);
321
+ return fs.statSync(fullPath).isDirectory();
322
+ })
323
+ .sort();
324
+ }
325
+
326
+ /**
327
+ * Lists app names (directories) under builder root. Excludes dot-prefixed entries.
328
+ * Returns [] if root does not exist.
329
+ * @returns {string[]} Sorted list of app directory names
330
+ */
331
+ function listBuilderAppNames() {
332
+ const root = getBuilderRoot();
333
+ if (!fs.existsSync(root)) {
334
+ return [];
335
+ }
336
+ const stat = fs.statSync(root);
337
+ if (!stat || typeof stat.isDirectory !== 'function' || !stat.isDirectory()) {
338
+ return [];
339
+ }
340
+ const entries = fs.readdirSync(root);
341
+ return entries
342
+ .filter(name => !name.startsWith('.'))
343
+ .filter(name => {
344
+ const fullPath = path.join(root, name);
345
+ return fs.statSync(fullPath).isDirectory();
346
+ })
347
+ .sort();
348
+ }
349
+
282
350
  /**
283
351
  * Gets the integration folder path for external systems.
284
352
  * @param {string} appName - Application name
@@ -470,6 +538,14 @@ async function getResolveAppPath(appName) {
470
538
  return { appPath: result.appPath, envOnly: false };
471
539
  }
472
540
 
541
+ /** Resolve appKey when cwd is inside integration/<appKey>/. */
542
+ function resolveIntegrationAppKeyFromCwd() {
543
+ const integrationNorm = path.resolve(path.join(getIntegrationBuilderBaseDir(), 'integration'));
544
+ const cwd = path.resolve(process.cwd());
545
+ if (cwd !== integrationNorm && !cwd.startsWith(integrationNorm + path.sep)) return null;
546
+ return path.relative(integrationNorm, cwd).split(path.sep)[0] || null;
547
+ }
548
+
473
549
  module.exports = {
474
550
  getAifabrixHome,
475
551
  getConfigDirForPaths,
@@ -479,11 +555,16 @@ module.exports = {
479
555
  getProjectRoot,
480
556
  getIntegrationPath,
481
557
  getBuilderPath,
558
+ getIntegrationRoot,
559
+ getBuilderRoot,
560
+ listIntegrationAppNames,
561
+ listBuilderAppNames,
482
562
  resolveBuildContext,
483
563
  getDeployJsonPath,
484
564
  resolveApplicationConfigPath,
485
565
  detectAppType,
486
566
  getResolveAppPath,
567
+ resolveIntegrationAppKeyFromCwd,
487
568
  clearProjectRootCache
488
569
  };
489
570
 
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Canonical secrets path and YAML helpers
3
+ *
4
+ * @fileoverview Read/merge canonical secrets from config path
5
+ * @author AI Fabrix Team
6
+ * @version 2.0.0
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const yaml = require('js-yaml');
14
+ const config = require('../core/config');
15
+
16
+ /**
17
+ * Read a YAML file and return parsed object
18
+ * @function readYamlAtPath
19
+ * @param {string} filePath - Absolute file path
20
+ * @returns {Object} Parsed YAML object
21
+ */
22
+ function readYamlAtPath(filePath) {
23
+ const content = fs.readFileSync(filePath, 'utf8');
24
+ return yaml.load(content);
25
+ }
26
+
27
+ /**
28
+ * Merge a single secret value from canonical into result
29
+ * @function mergeSecretValue
30
+ * @param {Object} result - Result object to merge into
31
+ * @param {string} key - Secret key
32
+ * @param {*} canonicalValue - Value from canonical secrets
33
+ */
34
+ function mergeSecretValue(result, key, canonicalValue) {
35
+ const currentValue = result[key];
36
+ // Fill missing, empty, or undefined values
37
+ if (!(key in result) || currentValue === undefined || currentValue === null || currentValue === '') {
38
+ result[key] = canonicalValue;
39
+ return;
40
+ }
41
+ // Only replace values that are encrypted (have secure:// prefix)
42
+ // Plaintext values (no secure://) are used as-is
43
+ if (typeof currentValue === 'string' && typeof canonicalValue === 'string') {
44
+ if (currentValue.startsWith('secure://')) {
45
+ result[key] = canonicalValue;
46
+ }
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Apply canonical secrets path override if configured and file exists
52
+ * @async
53
+ * @function applyCanonicalSecretsOverride
54
+ * @param {Object} currentSecrets - Current secrets map
55
+ * @returns {Promise<Object>} Possibly overridden secrets
56
+ */
57
+ async function applyCanonicalSecretsOverride(currentSecrets) {
58
+ let mergedSecrets = currentSecrets || {};
59
+ try {
60
+ const canonicalPath = await config.getSecretsPath();
61
+ if (!canonicalPath) {
62
+ return mergedSecrets;
63
+ }
64
+ const resolvedCanonical = path.isAbsolute(canonicalPath)
65
+ ? canonicalPath
66
+ : path.resolve(process.cwd(), canonicalPath);
67
+ if (!fs.existsSync(resolvedCanonical)) {
68
+ return mergedSecrets;
69
+ }
70
+ const configSecrets = readYamlAtPath(resolvedCanonical);
71
+ if (!configSecrets || typeof configSecrets !== 'object') {
72
+ return mergedSecrets;
73
+ }
74
+ // Apply canonical secrets as a fallback source:
75
+ // - Do NOT override any existing keys from user/build
76
+ // - Add only missing keys from canonical path
77
+ // - Also fill in empty/undefined values from canonical path
78
+ // - Replace encrypted values (secure://) with canonical plaintext
79
+ const result = { ...mergedSecrets };
80
+ for (const [key, canonicalValue] of Object.entries(configSecrets)) {
81
+ mergeSecretValue(result, key, canonicalValue);
82
+ }
83
+ mergedSecrets = result;
84
+ } catch {
85
+ // ignore and fall through
86
+ }
87
+ return mergedSecrets;
88
+ }
89
+
90
+ module.exports = {
91
+ readYamlAtPath,
92
+ applyCanonicalSecretsOverride
93
+ };
@@ -208,6 +208,25 @@ function saveSecretsFile(resolvedPath, secrets) {
208
208
 
209
209
  const YAML_DUMP_OPTS = { indent: 2, lineWidth: 120, noRefs: true, sortKeys: false };
210
210
 
211
+ /**
212
+ * Merges secret keys into the secrets file (load existing, merge, overwrite file).
213
+ * Use when setting or updating keys so that existing keys are updated in place instead of duplicated.
214
+ * Creates the file if it does not exist. Tolerant of duplicate keys in existing file (last wins when loading).
215
+ *
216
+ * @function mergeSecretsIntoFile
217
+ * @param {string} resolvedPath - Path to secrets file
218
+ * @param {Object} secrets - Key-value object to merge (overwrites existing same keys)
219
+ * @throws {Error} If write fails
220
+ */
221
+ function mergeSecretsIntoFile(resolvedPath, secrets) {
222
+ if (!secrets || typeof secrets !== 'object' || Object.keys(secrets).length === 0) {
223
+ return;
224
+ }
225
+ const existing = loadExistingSecrets(resolvedPath);
226
+ const merged = { ...existing, ...secrets };
227
+ saveSecretsFile(resolvedPath, merged);
228
+ }
229
+
211
230
  /**
212
231
  * Appends secret keys to the end of the secrets file without modifying existing content (preserves comments and structure).
213
232
  * Creates the file if it does not exist. For existing files, new keys are appended.
@@ -329,6 +348,7 @@ module.exports = {
329
348
  loadYamlTolerantOfDuplicateKeys,
330
349
  loadExistingSecrets,
331
350
  saveSecretsFile,
351
+ mergeSecretsIntoFile,
332
352
  appendSecretsToFile,
333
353
  generateMissingSecrets,
334
354
  createDefaultSecrets
@@ -18,6 +18,7 @@ const { loadEnvConfig } = require('./env-config-loader');
18
18
  const { updateContainerPortInEnvFile } = require('./env-ports');
19
19
  const { buildEnvVarMap } = require('./env-map');
20
20
  const { getLocalPortFromPath } = require('./port-resolver');
21
+ const { readYamlAtPath, applyCanonicalSecretsOverride } = require('./secrets-canonical');
21
22
 
22
23
  /**
23
24
  * Interpolate ${VAR} occurrences with values from envVars map
@@ -42,25 +43,84 @@ function isCommentOrEmptyLine(line) {
42
43
  return t === '' || t.startsWith('#');
43
44
  }
44
45
 
46
+ /** Regex for kv:// path (allows slashes, e.g. kv://hubspot/clientId) */
47
+ const KV_REF_PATTERN = /kv:\/\/([a-zA-Z0-9_\-/]+)/g;
48
+
49
+ /**
50
+ * Find object key that matches part case-insensitively.
51
+ * @param {Object} obj - Object to search
52
+ * @param {string} part - Key to match (e.g. 'clientid')
53
+ * @returns {string|undefined} Actual key in obj or undefined
54
+ */
55
+ function findKeyCaseInsensitive(obj, part) {
56
+ if (!obj || typeof obj !== 'object' || part === null || part === undefined) return undefined;
57
+ const lower = String(part).toLowerCase();
58
+ for (const key of Object.keys(obj)) {
59
+ if (key.toLowerCase() === lower) return key;
60
+ }
61
+ return undefined;
62
+ }
63
+
64
+ /**
65
+ * Resolve value by walking path parts (nested keys).
66
+ * @param {Object} secrets - Root secrets object
67
+ * @param {string[]} parts - Path parts (e.g. ['hubspot', 'clientId'])
68
+ * @returns {*} Value or undefined
69
+ */
70
+ function getValueByNestedPath(secrets, parts) {
71
+ let value = secrets;
72
+ for (const part of parts) {
73
+ if (!value || typeof value !== 'object') return undefined;
74
+ const key = part in value ? part : findKeyCaseInsensitive(value, part);
75
+ value = key !== undefined ? value[key] : undefined;
76
+ if (value === undefined) return undefined;
77
+ }
78
+ return value;
79
+ }
80
+
45
81
  /**
46
- * Collect missing kv:// secrets referenced in content (skips commented and empty lines)
82
+ * Get secret value by path. Supports flat key (hubspot/clientId), nested object (hubspot.clientId),
83
+ * and case-insensitive matching (clientid matches clientId). Path-style and hyphen-style are distinct:
84
+ * hubspot/clientid and hubspot-clientid are different keys.
85
+ * @param {Object} secrets - Secrets object (may be nested)
86
+ * @param {string} pathStr - Path after kv:// (e.g. 'hubspot/clientId' or 'hubspot/clientid')
87
+ * @returns {*} Value or undefined if not found
88
+ */
89
+ function getValueByPath(secrets, pathStr) {
90
+ if (!secrets || typeof secrets !== 'object' || !pathStr) {
91
+ return undefined;
92
+ }
93
+ const direct = secrets[pathStr];
94
+ if (direct !== undefined) return direct;
95
+ const flatKey = findKeyCaseInsensitive(secrets, pathStr);
96
+ if (flatKey !== undefined) return secrets[flatKey];
97
+ if (!pathStr.includes('/')) return undefined;
98
+ return getValueByNestedPath(secrets, pathStr.split('/'));
99
+ }
100
+
101
+ /**
102
+ * Collect missing kv:// secrets referenced in content (skips commented and empty lines).
103
+ * Supports path-style refs (e.g. kv://hubspot/clientId). Returns unique refs.
47
104
  * @function collectMissingSecrets
48
105
  * @param {string} content - Text content
49
- * @param {Object} secrets - Available secrets
50
- * @returns {string[]} Array of missing kv://<key> references
106
+ * @param {Object} secrets - Available secrets (flat or nested)
107
+ * @returns {string[]} Array of missing kv://<path> references (unique)
51
108
  */
52
109
  function collectMissingSecrets(content, secrets) {
53
- const kvPattern = /kv:\/\/([a-zA-Z0-9-_]+)/g;
110
+ const seen = new Set();
54
111
  const missing = [];
55
112
  const lines = content.split('\n');
56
113
  for (const line of lines) {
57
114
  if (isCommentOrEmptyLine(line)) continue;
58
115
  let match;
59
- kvPattern.lastIndex = 0;
60
- while ((match = kvPattern.exec(line)) !== null) {
61
- const secretKey = match[1];
62
- if (!(secretKey in secrets)) {
63
- missing.push(`kv://${secretKey}`);
116
+ KV_REF_PATTERN.lastIndex = 0;
117
+ while ((match = KV_REF_PATTERN.exec(line)) !== null) {
118
+ const pathStr = match[1];
119
+ if (seen.has(pathStr)) continue;
120
+ seen.add(pathStr);
121
+ const value = getValueByPath(secrets, pathStr);
122
+ if (value === undefined || value === null) {
123
+ missing.push(`kv://${pathStr}`);
64
124
  }
65
125
  }
66
126
  }
@@ -91,26 +151,26 @@ function formatMissingSecretsFileInfo(secretsFilePaths) {
91
151
  }
92
152
 
93
153
  /**
94
- * Replace kv:// references with actual values (skips commented and empty lines)
154
+ * Replace kv:// references with actual values (skips commented and empty lines).
155
+ * Supports path-style refs (e.g. kv://hubspot/clientId) and nested secrets.
95
156
  * @function replaceKvInContent
96
157
  * @param {string} content - Text content containing kv:// references
97
- * @param {Object} secrets - Secrets map
158
+ * @param {Object} secrets - Secrets map (flat or nested)
98
159
  * @param {Object} envVars - Environment variables map for nested interpolation
99
160
  * @returns {string} Content with kv:// references replaced
100
161
  */
101
162
  function replaceKvInContent(content, secrets, envVars) {
102
- const kvPattern = /kv:\/\/([a-zA-Z0-9-_]+)/g;
103
163
  const lines = content.split('\n');
104
164
  const result = lines.map(line => {
105
165
  if (isCommentOrEmptyLine(line)) return line;
106
- return line.replace(kvPattern, (match, secretKey) => {
107
- let value = secrets[secretKey];
166
+ return line.replace(KV_REF_PATTERN, (match, pathStr) => {
167
+ let value = getValueByPath(secrets, pathStr);
108
168
  if (typeof value === 'string') {
109
169
  value = value.replace(/\$\{([A-Z_]+)\}/g, (m, envVar) => {
110
170
  return envVars[envVar] || m;
111
171
  });
112
172
  }
113
- return value;
173
+ return value !== null && value !== undefined ? String(value) : match;
114
174
  });
115
175
  });
116
176
  return result.join('\n');
@@ -377,80 +437,6 @@ async function adjustLocalEnvPortsInContent(envContent, variablesPath) {
377
437
  return updated;
378
438
  }
379
439
 
380
- /**
381
- * Read a YAML file and return parsed object
382
- * @function readYamlAtPath
383
- * @param {string} filePath - Absolute file path
384
- * @returns {Object} Parsed YAML object
385
- */
386
- function readYamlAtPath(filePath) {
387
- const content = fs.readFileSync(filePath, 'utf8');
388
- return yaml.load(content);
389
- }
390
-
391
- /**
392
- * Merge a single secret value from canonical into result
393
- * @function mergeSecretValue
394
- * @param {Object} result - Result object to merge into
395
- * @param {string} key - Secret key
396
- * @param {*} canonicalValue - Value from canonical secrets
397
- */
398
- function mergeSecretValue(result, key, canonicalValue) {
399
- const currentValue = result[key];
400
- // Fill missing, empty, or undefined values
401
- if (!(key in result) || currentValue === undefined || currentValue === null || currentValue === '') {
402
- result[key] = canonicalValue;
403
- return;
404
- }
405
- // Only replace values that are encrypted (have secure:// prefix)
406
- // Plaintext values (no secure://) are used as-is
407
- if (typeof currentValue === 'string' && typeof canonicalValue === 'string') {
408
- if (currentValue.startsWith('secure://')) {
409
- result[key] = canonicalValue;
410
- }
411
- }
412
- }
413
-
414
- /**
415
- * Apply canonical secrets path override if configured and file exists
416
- * @async
417
- * @function applyCanonicalSecretsOverride
418
- * @param {Object} currentSecrets - Current secrets map
419
- * @returns {Promise<Object>} Possibly overridden secrets
420
- */
421
- async function applyCanonicalSecretsOverride(currentSecrets) {
422
- let mergedSecrets = currentSecrets || {};
423
- try {
424
- const canonicalPath = await config.getSecretsPath();
425
- if (!canonicalPath) {
426
- return mergedSecrets;
427
- }
428
- const resolvedCanonical = path.isAbsolute(canonicalPath)
429
- ? canonicalPath
430
- : path.resolve(process.cwd(), canonicalPath);
431
- if (!fs.existsSync(resolvedCanonical)) {
432
- return mergedSecrets;
433
- }
434
- const configSecrets = readYamlAtPath(resolvedCanonical);
435
- if (!configSecrets || typeof configSecrets !== 'object') {
436
- return mergedSecrets;
437
- }
438
- // Apply canonical secrets as a fallback source:
439
- // - Do NOT override any existing keys from user/build
440
- // - Add only missing keys from canonical path
441
- // - Also fill in empty/undefined values from canonical path
442
- // - Replace encrypted values (secure://) with canonical plaintext
443
- const result = { ...mergedSecrets };
444
- for (const [key, canonicalValue] of Object.entries(configSecrets)) {
445
- mergeSecretValue(result, key, canonicalValue);
446
- }
447
- mergedSecrets = result;
448
- } catch {
449
- // ignore and fall through
450
- }
451
- return mergedSecrets;
452
- }
453
-
454
440
  /**
455
441
  * Ensure secrets map is non-empty or throw a friendly guidance error
456
442
  * @function ensureNonEmptySecrets
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Test log writer - writes debug logs to integration/<appKey>/logs/
3
+ * Sanitization (tokens, secrets) is done by dataplane before responses are returned.
4
+ *
5
+ * @fileoverview Write test request/response logs for debugging
6
+ * @author AI Fabrix Team
7
+ * @version 2.0.0
8
+ */
9
+
10
+ const fs = require('fs').promises;
11
+ const path = require('path');
12
+
13
+ /**
14
+ * Prepare object for JSON serialization (handles circular refs)
15
+ * @param {*} obj - Object to prepare
16
+ * @param {Set} [seen] - Set of seen object references (for circular refs)
17
+ * @returns {*} Copy safe for JSON.stringify
18
+ */
19
+ function sanitizeForLog(obj, seen = new Set()) {
20
+ if (obj === null || obj === undefined || typeof obj !== 'object') return obj;
21
+ if (seen.has(obj)) return '[Circular]';
22
+ seen.add(obj);
23
+ if (Array.isArray(obj)) return obj.map(item => sanitizeForLog(item, seen));
24
+ const out = {};
25
+ for (const [key, value] of Object.entries(obj)) {
26
+ out[key] = sanitizeForLog(value, seen);
27
+ }
28
+ return out;
29
+ }
30
+
31
+ /**
32
+ * Write test log to integration/<appKey>/logs/<logType>-<timestamp>.json
33
+ * @async
34
+ * @param {string} appKey - Application key (used for path)
35
+ * @param {Object} data - Log data (request, response) - will be sanitized
36
+ * @param {string} [logType] - Log type prefix (default: test-integration)
37
+ * @param {string} [integrationBaseDir] - Base dir for integration (default: cwd/integration)
38
+ * @returns {Promise<string>} Path to written file
39
+ * @throws {Error} If write fails
40
+ */
41
+ async function writeTestLog(appKey, data, logType = 'test-integration', integrationBaseDir) {
42
+ const baseDir = integrationBaseDir || path.join(process.cwd(), 'integration');
43
+ const logsDir = path.join(baseDir, appKey, 'logs');
44
+ await fs.mkdir(logsDir, { recursive: true });
45
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
46
+ const filename = `${logType}-${timestamp}.json`;
47
+ const filePath = path.join(logsDir, filename);
48
+ const sanitized = sanitizeForLog(data);
49
+ await fs.writeFile(filePath, JSON.stringify(sanitized, null, 2), 'utf8');
50
+ return filePath;
51
+ }
52
+
53
+ module.exports = {
54
+ sanitizeForLog,
55
+ writeTestLog
56
+ };
@@ -247,48 +247,29 @@ async function tryClientTokenAuth(environment, appName, controllerUrl) {
247
247
  const clientToken = await getOrRefreshClientToken(environment, appName, controllerUrl);
248
248
  if (clientToken && clientToken.token) {
249
249
  return {
250
- type: 'bearer',
250
+ type: 'client-token',
251
251
  token: clientToken.token,
252
252
  controller: clientToken.controller
253
253
  };
254
254
  }
255
255
  } catch {
256
- // Client token unavailable; getDeploymentAuth will try client credentials next (no warning here to avoid misleading output when env credentials succeed)
257
- }
258
- return null;
259
- }
260
-
261
- /**
262
- * Tries to get client credentials for deployment auth
263
- * @async
264
- * @function tryClientCredentialsAuth
265
- * @param {string} appName - Application name
266
- * @param {string} controllerUrl - Controller URL
267
- * @returns {Promise<Object|null>} Auth config with client credentials or null
268
- */
269
- async function tryClientCredentialsAuth(appName, controllerUrl) {
270
- const credentials = await loadClientCredentials(appName);
271
- if (credentials && credentials.clientId && credentials.clientSecret) {
272
- return {
273
- type: 'client-credentials',
274
- clientId: credentials.clientId,
275
- clientSecret: credentials.clientSecret,
276
- controller: controllerUrl
277
- };
256
+ // Client token unavailable; getDeploymentAuth will try exchanging credentials for token (no warning here to avoid misleading output when refresh succeeds)
278
257
  }
279
258
  return null;
280
259
  }
281
260
 
282
261
  /**
283
262
  * Get deployment authentication configuration with priority:
284
- * 1. Device token (Bearer) - for user-level audit tracking (preferred)
285
- * 2. Client token (Bearer) - for application-level authentication
286
- * 3. Client credentials (x-client-id/x-client-secret) - direct credential authentication
263
+ * 1. Device token → type 'bearer' (user token) send as Authorization: Bearer
264
+ * 2. Client token → type 'client-token' (application token) send as x-client-token header
265
+ * 3. When no token available: if client credentials exist, exchange for client token and return type 'client-token'.
266
+ *
267
+ * x-client-id/x-client-secret are used only at the token-issuing endpoint (e.g. POST /api/v1/auth/token).
287
268
  *
288
269
  * @param {string} controllerUrl - Controller URL
289
270
  * @param {string} environment - Environment key
290
271
  * @param {string} appName - Application name
291
- * @returns {Promise<{type: 'bearer'|'client-credentials', token?: string, clientId?: string, clientSecret?: string, controller: string}>} Auth configuration
272
+ * @returns {Promise<{type: 'bearer'|'client-token', token: string, controller: string}>} Auth config: bearer = user token, client-token = app token (x-client-token header)
292
273
  * @throws {Error} If no authentication method is available
293
274
  */
294
275
  async function getDeploymentAuth(controllerUrl, environment, appName) {
@@ -306,10 +287,21 @@ async function getDeploymentAuth(controllerUrl, environment, appName) {
306
287
  return clientTokenAuth;
307
288
  }
308
289
 
309
- // Priority 3: Use client credentials directly
310
- const credentialsAuth = await tryClientCredentialsAuth(appName, controllerUrl);
311
- if (credentialsAuth) {
312
- return credentialsAuth;
290
+ // Priority 3: Exchange client credentials for a token (never return client-credentials for app endpoints)
291
+ const credentials = await loadClientCredentials(appName);
292
+ if (credentials && credentials.clientId && credentials.clientSecret) {
293
+ try {
294
+ const refreshed = await refreshClientToken(environment, appName, controllerUrl);
295
+ if (refreshed && refreshed.token) {
296
+ return {
297
+ type: 'client-token',
298
+ token: refreshed.token,
299
+ controller: controllerUrl
300
+ };
301
+ }
302
+ } catch {
303
+ // Refresh failed; fall through to throw below
304
+ }
313
305
  }
314
306
 
315
307
  throw new Error(`No authentication method available. Run 'aifabrix login' for device token, or add credentials to ~/.aifabrix/secrets.local.yaml as '${appName}-client-idKeyVault' and '${appName}-client-secretKeyVault'`);
@@ -337,7 +329,7 @@ async function extractClientCredentials(authConfig, appKey, envKey, _options = {
337
329
  };
338
330
  }
339
331
 
340
- if (authConfig.type === 'bearer') {
332
+ if (authConfig.type === 'bearer' || authConfig.type === 'client-token') {
341
333
  if (authConfig.clientId && authConfig.clientSecret) {
342
334
  return {
343
335
  clientId: authConfig.clientId,