@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
@@ -91,6 +91,33 @@ function normalizeDatasources(datasources, systemKey, fileExt = '.json') {
91
91
  );
92
92
  }
93
93
 
94
+ /**
95
+ * Builds secret path entries for README "Secrets" section per auth type.
96
+ * Path is the key for `aifabrix secret set <key> <value>` (no kv:// prefix; key format systemKey/secretKey in camelCase).
97
+ * @param {string} systemKey - System key
98
+ * @param {string} [authType] - Authentication type (oauth2, aad, apikey, basic, queryParam, hmac, bearer, token, none)
99
+ * @returns {Array<{path: string, description: string}>} secretPaths for template (path = key for secret set, no kv://)
100
+ */
101
+ function buildSecretPaths(systemKey, authType) {
102
+ if (!systemKey || typeof systemKey !== 'string') return [];
103
+ const t = (authType && typeof authType === 'string') ? authType.toLowerCase() : 'apikey';
104
+ const map = {
105
+ oauth2: [{ path: `${systemKey}/clientId`, description: 'Client ID' }, { path: `${systemKey}/clientSecret`, description: 'Client Secret' }],
106
+ oauth: [{ path: `${systemKey}/clientId`, description: 'Client ID' }, { path: `${systemKey}/clientSecret`, description: 'Client Secret' }],
107
+ aad: [{ path: `${systemKey}/clientId`, description: 'Client ID' }, { path: `${systemKey}/clientSecret`, description: 'Client Secret' }],
108
+ apikey: [{ path: `${systemKey}/apiKey`, description: 'API Key' }],
109
+ apiKey: [{ path: `${systemKey}/apiKey`, description: 'API Key' }],
110
+ basic: [{ path: `${systemKey}/username`, description: 'Username' }, { path: `${systemKey}/password`, description: 'Password' }],
111
+ queryparam: [{ path: `${systemKey}/paramValue`, description: 'Query parameter value' }],
112
+ hmac: [{ path: `${systemKey}/signingSecret`, description: 'Signing secret' }],
113
+ bearer: [{ path: `${systemKey}/bearerToken`, description: 'Bearer token' }],
114
+ token: [{ path: `${systemKey}/bearerToken`, description: 'Bearer token' }],
115
+ oidc: [],
116
+ none: []
117
+ };
118
+ return map[t] || map.apikey;
119
+ }
120
+
94
121
  /**
95
122
  * Builds the external system README template context
96
123
  * @function buildExternalReadmeContext
@@ -102,6 +129,8 @@ function normalizeDatasources(datasources, systemKey, fileExt = '.json') {
102
129
  * @param {string} [params.description] - Description
103
130
  * @param {Array} [params.datasources] - Datasource objects
104
131
  * @param {string} [params.fileExt] - File extension for config files (e.g. '.json', '.yaml'); default '.json'
132
+ * @param {string} [params.authType] - Authentication type for Secrets section (oauth2, aad, apikey, basic, etc.)
133
+ * @param {Object} [params.authentication] - Full authentication object (authType used if authType not set)
105
134
  * @returns {Object} Template context
106
135
  */
107
136
  function buildExternalReadmeContext(params = {}) {
@@ -112,6 +141,8 @@ function buildExternalReadmeContext(params = {}) {
112
141
  const systemType = params.systemType || 'openapi';
113
142
  const fileExt = params.fileExt !== undefined ? params.fileExt : '.json';
114
143
  const datasources = normalizeDatasources(params.datasources, systemKey, fileExt);
144
+ const authType = params.authType || params.authentication?.type || params.authentication?.method || params.authentication?.authType;
145
+ const secretPaths = buildSecretPaths(systemKey, authType);
115
146
 
116
147
  return {
117
148
  appName,
@@ -122,7 +153,8 @@ function buildExternalReadmeContext(params = {}) {
122
153
  fileExt: fileExt && fileExt.startsWith('.') ? fileExt : `.${fileExt || 'json'}`,
123
154
  datasourceCount: datasources.length,
124
155
  hasDatasources: datasources.length > 0,
125
- datasources
156
+ datasources,
157
+ secretPaths
126
158
  };
127
159
  }
128
160
 
@@ -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,
@@ -198,7 +198,7 @@ function saveSecretsFile(resolvedPath, secrets) {
198
198
 
199
199
  const yamlContent = yaml.dump(secrets, {
200
200
  indent: 2,
201
- lineWidth: 120,
201
+ lineWidth: -1,
202
202
  noRefs: true,
203
203
  sortKeys: false
204
204
  });
@@ -206,7 +206,7 @@ function saveSecretsFile(resolvedPath, secrets) {
206
206
  fs.writeFileSync(resolvedPath, yamlContent, { mode: 0o600 });
207
207
  }
208
208
 
209
- const YAML_DUMP_OPTS = { indent: 2, lineWidth: 120, noRefs: true, sortKeys: false };
209
+ const YAML_DUMP_OPTS = { indent: 2, lineWidth: -1, noRefs: true, sortKeys: false };
210
210
 
211
211
  /**
212
212
  * Merges secret keys into the secrets file (load existing, merge, overwrite file).
@@ -14,6 +14,7 @@ const path = require('path');
14
14
  const yaml = require('js-yaml');
15
15
  const logger = require('./logger');
16
16
  const pathsUtil = require('./paths');
17
+ const { ensureSecureFilePermissions } = require('./secure-file-permissions');
17
18
  const { getContainerPort } = require('./port-resolver');
18
19
  const { loadYamlTolerantOfDuplicateKeys } = require('./secrets-generator');
19
20
 
@@ -78,6 +79,7 @@ function loadPrimaryUserSecrets() {
78
79
  if (!fs.existsSync(userSecretsPath)) {
79
80
  return {};
80
81
  }
82
+ ensureSecureFilePermissions(userSecretsPath);
81
83
 
82
84
  try {
83
85
  const content = fs.readFileSync(userSecretsPath, 'utf8');
@@ -106,6 +108,7 @@ function loadUserSecrets() {
106
108
  if (!fs.existsSync(userSecretsPath)) {
107
109
  return {};
108
110
  }
111
+ ensureSecureFilePermissions(userSecretsPath);
109
112
 
110
113
  try {
111
114
  const content = fs.readFileSync(userSecretsPath, 'utf8');
@@ -134,6 +137,7 @@ function loadDefaultSecrets() {
134
137
  if (!fs.existsSync(defaultPath)) {
135
138
  return {};
136
139
  }
140
+ ensureSecureFilePermissions(defaultPath);
137
141
 
138
142
  try {
139
143
  const content = fs.readFileSync(defaultPath, 'utf8');
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Secure file permissions for secrets and config (ISO 27001).
3
+ * Ensures sensitive files are restricted to owner-only (0o600) when read or written.
4
+ *
5
+ * @fileoverview Enforce restrictive permissions on secrets and config files
6
+ * @author AI Fabrix Team
7
+ * @version 2.0.0
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ /** Mode for secrets and admin files: owner read/write only (no group/other). */
16
+ const SECRET_FILE_MODE = 0o600;
17
+
18
+ /** Mode for config file (may contain tokens): owner read/write only. */
19
+ const CONFIG_FILE_MODE = 0o600;
20
+
21
+ /**
22
+ * Ensures a file has restrictive permissions (0o600) when it exists.
23
+ * If the file has group or other read/write/execute bits set, chmods to owner-only.
24
+ * Safe to call on every read path; no-op when file is missing or already 0o600.
25
+ * On Windows, chmod restricts write access; mode bits are not fully supported.
26
+ *
27
+ * @param {string} filePath - Absolute or relative path to the file
28
+ * @param {number} [mode=0o600] - Desired mode (default SECRET_FILE_MODE)
29
+ * @returns {boolean} True if file existed and permissions were (or are now) secure
30
+ */
31
+ function ensureSecureFilePermissions(filePath, mode = SECRET_FILE_MODE) {
32
+ if (!filePath || typeof filePath !== 'string') {
33
+ return false;
34
+ }
35
+ const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
36
+ try {
37
+ if (!fs.existsSync(resolved)) {
38
+ return false;
39
+ }
40
+ const stat = fs.statSync(resolved);
41
+ if (!stat.isFile()) {
42
+ return false;
43
+ }
44
+ const currentMode = stat.mode & 0o777;
45
+ if ((currentMode & 0o77) === 0) {
46
+ return true;
47
+ }
48
+ fs.chmodSync(resolved, mode);
49
+ return true;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Ensures a directory has restrictive permissions (0o700) when it exists.
57
+ * No-op when directory is missing or already 0o700 (no group/other).
58
+ *
59
+ * @param {string} dirPath - Absolute or relative path to the directory
60
+ * @returns {boolean} True if directory existed and permissions were (or are now) secure
61
+ */
62
+ function ensureSecureDirPermissions(dirPath) {
63
+ if (!dirPath || typeof dirPath !== 'string') {
64
+ return false;
65
+ }
66
+ const resolved = path.isAbsolute(dirPath) ? dirPath : path.resolve(dirPath);
67
+ try {
68
+ if (!fs.existsSync(resolved)) {
69
+ return false;
70
+ }
71
+ const stat = fs.statSync(resolved);
72
+ if (!stat.isDirectory()) {
73
+ return false;
74
+ }
75
+ const currentMode = stat.mode & 0o777;
76
+ if ((currentMode & 0o77) === 0) {
77
+ return true;
78
+ }
79
+ fs.chmodSync(resolved, 0o700);
80
+ return true;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ module.exports = {
87
+ ensureSecureFilePermissions,
88
+ ensureSecureDirPermissions,
89
+ SECRET_FILE_MODE,
90
+ CONFIG_FILE_MODE
91
+ };
@@ -21,12 +21,43 @@ const {
21
21
  } = require('./token-manager-refresh');
22
22
  const { warnRefreshFailureOnce, warnRefreshTokenExpiredOnce } = require('./token-manager-messages');
23
23
 
24
+ /** App key used for dataplane client credentials in secrets.local.yaml */
25
+ const DATAPLANE_APP_KEY = 'dataplane';
26
+
24
27
  function getSecretsFilePath() {
25
28
  return path.join(pathsUtil.getAifabrixHome(), 'secrets.local.yaml');
26
29
  }
27
30
 
28
31
  /**
29
- * Load client credentials from secrets.local.yaml or process.env (e.g. integration/hubspot/.env).
32
+ * Validate that secrets.local.yaml contains dataplane client credentials.
33
+ * If missing, the developer should run: aifabrix app rotate-secret dataplane
34
+ * @param {string} [secretsFilePath] - Path to secrets file; defaults to ~/.aifabrix/secrets.local.yaml
35
+ * @returns {{ valid: boolean, hint?: string }}
36
+ */
37
+ function validateDataplaneSecrets(secretsFilePath) {
38
+ const filePath = secretsFilePath || getSecretsFilePath();
39
+ const hint = 'Dataplane credentials are missing. Run: aifabrix app rotate-secret dataplane';
40
+ try {
41
+ if (!fs.existsSync(filePath)) {
42
+ return { valid: false, hint };
43
+ }
44
+ const content = fs.readFileSync(filePath, 'utf8');
45
+ const secrets = yaml.load(content) || {};
46
+ const clientIdKey = `${DATAPLANE_APP_KEY}-client-idKeyVault`;
47
+ const clientSecretKey = `${DATAPLANE_APP_KEY}-client-secretKeyVault`;
48
+ const hasId = secrets[clientIdKey] !== null && secrets[clientIdKey] !== undefined && String(secrets[clientIdKey]).trim() !== '';
49
+ const hasSecret = secrets[clientSecretKey] !== null && secrets[clientSecretKey] !== undefined && String(secrets[clientSecretKey]).trim() !== '';
50
+ if (hasId && hasSecret) {
51
+ return { valid: true };
52
+ }
53
+ return { valid: false, hint };
54
+ } catch {
55
+ return { valid: false, hint };
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Load client credentials from secrets.local.yaml or process.env (e.g. integration/hubspot-test/.env).
30
61
  * Reads secrets file using pattern: <app-name>-client-idKeyVault and <app-name>-client-secretKeyVault.
31
62
  * If not found, checks process.env.CLIENTID and process.env.CLIENTSECRET (set when .env is loaded).
32
63
  * @param {string} appName - Application name
@@ -60,7 +91,7 @@ async function loadClientCredentials(appName) {
60
91
  logger.warn(`Failed to load credentials from secrets.local.yaml: ${error.message}`);
61
92
  }
62
93
 
63
- // Fallback: use CLIENTID/CLIENTSECRET from process.env (e.g. from integration/hubspot/.env)
94
+ // Fallback: use CLIENTID/CLIENTSECRET from process.env (e.g. from integration/hubspot-test/.env)
64
95
  const envClientId = process.env.CLIENTID || process.env.CLIENT_ID;
65
96
  const envClientSecret = process.env.CLIENTSECRET || process.env.CLIENT_SECRET;
66
97
  if (envClientId && envClientSecret) {
@@ -439,6 +470,7 @@ function requireBearerForDataplanePipeline(authConfig) {
439
470
  }
440
471
 
441
472
  module.exports = {
473
+ DATAPLANE_APP_KEY,
442
474
  getDeviceToken,
443
475
  getClientToken,
444
476
  isTokenExpired,
@@ -452,5 +484,6 @@ module.exports = {
452
484
  getDeploymentAuth,
453
485
  getDeviceOnlyAuth,
454
486
  extractClientCredentials,
455
- requireBearerForDataplanePipeline
487
+ requireBearerForDataplanePipeline,
488
+ validateDataplaneSecrets
456
489
  };
@@ -185,6 +185,9 @@ function formatValue(value, quoted, quoteChar) {
185
185
  * const result = encryptYamlValues(yamlContent, encryptionKey);
186
186
  * // Returns: { content: '...', encrypted: 5, total: 10 }
187
187
  */
188
+ /** Pattern for YAML block scalar indicator (e.g. key: >- or key: |) */
189
+ const BLOCK_SCALAR_PATTERN = /^(\s*)([^#:\n]+?):\s*(\|[-+]?|>[-+]?)(\s*)(#.*)?$/;
190
+
188
191
  /**
189
192
  * Processes a single line for encryption
190
193
  * @function processLineForEncryption
@@ -221,13 +224,68 @@ function processLineForEncryption(line, encryptionKey, stats) {
221
224
  return line;
222
225
  }
223
226
 
227
+ /**
228
+ * Collects continuation lines for a YAML block scalar (value on next lines).
229
+ * @param {string[]} lines - All lines
230
+ * @param {number} startIndex - Index of line after the key: >- line
231
+ * @param {number} keyIndentLen - Number of leading spaces on the key line
232
+ * @returns {{ value: string, endIndex: number }} Combined value and index after last continuation line
233
+ */
234
+ function collectBlockScalarLines(lines, startIndex, keyIndentLen) {
235
+ const parts = [];
236
+ let i = startIndex;
237
+ while (i < lines.length) {
238
+ const line = lines[i];
239
+ const contentTrimmed = line.trim();
240
+ if (contentTrimmed === '' || contentTrimmed.startsWith('#')) {
241
+ i++;
242
+ continue;
243
+ }
244
+ const indentLen = line.length - line.trimStart().length;
245
+ if (indentLen <= keyIndentLen) {
246
+ break;
247
+ }
248
+ parts.push(contentTrimmed);
249
+ i++;
250
+ }
251
+ return { value: parts.join(' '), endIndex: i };
252
+ }
253
+
224
254
  function encryptYamlValues(content, encryptionKey) {
225
255
  const lines = content.split(/\r?\n/);
226
256
  const encryptedLines = [];
227
257
  const stats = { encrypted: 0, total: 0 };
258
+ let i = 0;
259
+
260
+ while (i < lines.length) {
261
+ const line = lines[i];
262
+ const trimmed = line.trim();
263
+
264
+ if (trimmed === '' || trimmed.startsWith('#')) {
265
+ encryptedLines.push(line);
266
+ i++;
267
+ continue;
268
+ }
269
+
270
+ const blockMatch = line.match(BLOCK_SCALAR_PATTERN);
271
+ if (blockMatch) {
272
+ const [, indent, key, blockIndicator, trailingWhitespace, comment] = blockMatch;
273
+ const keyIndentLen = indent.length;
274
+ const folded = blockIndicator.startsWith('>');
275
+ const { value: rawValue, endIndex } = collectBlockScalarLines(lines, i + 1, keyIndentLen);
276
+ const value = folded ? rawValue.replace(/\s+/g, ' ').trim() : rawValue;
277
+ stats.total++;
278
+ const outValue = shouldEncryptValue(value) ? encryptSecret(value, encryptionKey) : value;
279
+ if (shouldEncryptValue(value)) {
280
+ stats.encrypted++;
281
+ }
282
+ encryptedLines.push(`${indent}${key}: ${outValue}${trailingWhitespace || ''}${comment || ''}`);
283
+ i = endIndex;
284
+ continue;
285
+ }
228
286
 
229
- for (const line of lines) {
230
287
  encryptedLines.push(processLineForEncryption(line, encryptionKey, stats));
288
+ i++;
231
289
  }
232
290
 
233
291
  return {
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  const { loadExternalIntegrationConfig, loadSystemFile } = require('../generator/external');
13
+ const { getKvPathSegmentForSecurityKey } = require('../utils/credential-secrets-env');
13
14
 
14
15
  /**
15
16
  * Extracts all kv:// paths from env.template content (RHS of VAR=value lines).
@@ -93,7 +94,7 @@ function setHasPathIgnoreCase(pathSet, requiredPath) {
93
94
  * @returns {Promise<{ requiredPaths: Set<string>, warning?: string }>} Required kv paths and optional warning
94
95
  *
95
96
  * @example
96
- * const { requiredPaths } = await collectRequiredAuthKvPaths('/path/to/integration/hubspot');
97
+ * const { requiredPaths } = await collectRequiredAuthKvPaths('/path/to/integration/hubspot-test');
97
98
  * // requiredPaths has kv:// paths from authentication.security
98
99
  */
99
100
  async function collectRequiredAuthKvPaths(appPath, _options = {}) {
@@ -148,10 +149,57 @@ async function validateAuthKvCoverage(appPath, content, errors, warnings, option
148
149
  }
149
150
  }
150
151
 
152
+ /**
153
+ * Derives system key from system file name (e.g. hubspot-system.yaml -> hubspot).
154
+ * @param {string} systemFileName - System file name
155
+ * @returns {string}
156
+ */
157
+ function systemKeyFromFileName(systemFileName) {
158
+ if (!systemFileName || typeof systemFileName !== 'string') return '';
159
+ return systemFileName.replace(/-system\.(yaml|yml|json)$/i, '');
160
+ }
161
+
162
+ /**
163
+ * Validates that authentication.security paths in system files match the canonical path
164
+ * (kv://<systemKey>/<getKvPathSegmentForSecurityKey(securityKey)>). Pushes errors when they differ.
165
+ *
166
+ * @async
167
+ * @function validateAuthSecurityPathConsistency
168
+ * @param {string} appPath - Application path (integration or builder dir)
169
+ * @param {string[]} errors - Errors array to push to
170
+ * @param {string[]} warnings - Warnings array to push to (unused; for API consistency)
171
+ */
172
+ async function validateAuthSecurityPathConsistency(appPath, errors, _warnings) {
173
+ try {
174
+ const { schemaBasePath, systemFiles } = await loadExternalIntegrationConfig(appPath);
175
+ for (const systemFileName of systemFiles) {
176
+ const systemKey = systemKeyFromFileName(systemFileName);
177
+ if (!systemKey) continue;
178
+ const systemJson = await loadSystemFile(appPath, schemaBasePath, systemFileName);
179
+ const security = systemJson.authentication?.security;
180
+ if (!security || typeof security !== 'object') continue;
181
+ for (const [key, value] of Object.entries(security)) {
182
+ if (typeof value !== 'string' || !/^kv:\/\/.+/.test(value)) continue;
183
+ const canonicalSegment = getKvPathSegmentForSecurityKey(key);
184
+ const canonicalPath = canonicalSegment ? `kv://${systemKey}/${canonicalSegment}` : null;
185
+ if (canonicalPath && value !== canonicalPath) {
186
+ errors.push(
187
+ `authentication.security.${key} has path ${value}; canonical path is ${canonicalPath}. Run \`aifabrix repair <app>\` to normalize.`
188
+ );
189
+ }
190
+ }
191
+ }
192
+ } catch (error) {
193
+ _warnings.push(`Could not validate auth path consistency: ${error.message}`);
194
+ }
195
+ }
196
+
151
197
  module.exports = {
152
198
  extractKvPathsFromEnvTemplate,
153
199
  extractKvPathsFromCommentedLines,
154
200
  setHasPathIgnoreCase,
155
201
  collectRequiredAuthKvPaths,
156
- validateAuthKvCoverage
202
+ validateAuthKvCoverage,
203
+ validateAuthSecurityPathConsistency,
204
+ systemKeyFromFileName
157
205
  };
@@ -13,6 +13,8 @@ const Ajv = require('ajv');
13
13
  const fs = require('fs');
14
14
  const path = require('path');
15
15
  const { formatValidationErrors } = require('../utils/error-formatter');
16
+ const { validateFieldReferences } = require('../datasource/field-reference-validator');
17
+ const { validateAbac } = require('../datasource/abac-validator');
16
18
 
17
19
  /**
18
20
  * Sets up AJV validator with external schemas
@@ -113,6 +115,12 @@ function validateDatasources(manifest, ajv, externalDatasourceSchema, errors, wa
113
115
  if (!datasourceValid) {
114
116
  const datasourceErrors = formatValidationErrors(validateDatasource.errors);
115
117
  errors.push(...datasourceErrors.map(err => `Datasource ${index + 1} (${datasource.key || 'unknown'}): ${err}`));
118
+ } else {
119
+ const fieldRefErrors = validateFieldReferences(datasource);
120
+ const abacErrors = validateAbac(datasource);
121
+ const prefix = `Datasource ${index + 1} (${datasource.key || 'unknown'}): `;
122
+ fieldRefErrors.forEach(e => errors.push(prefix + e));
123
+ abacErrors.forEach(e => errors.push(prefix + e));
116
124
  }
117
125
  });
118
126
  }
@@ -15,6 +15,8 @@ const validator = require('./validator');
15
15
  const { resolveExternalFiles } = require('../utils/schema-resolver');
16
16
  const { loadExternalSystemSchema, loadExternalDataSourceSchema, detectSchemaType } = require('../utils/schema-loader');
17
17
  const { formatValidationErrors } = require('../utils/error-formatter');
18
+ const { validateFieldReferences } = require('../datasource/field-reference-validator');
19
+ const { validateAbac } = require('../datasource/abac-validator');
18
20
  const { detectAppType } = require('../utils/paths');
19
21
  const batch = require('./validate-batch');
20
22
  const { logOfflinePathWhenType } = require('../utils/cli-utils');
@@ -200,6 +202,12 @@ async function validateExternalFile(filePath, type) {
200
202
  validateConfigurationNoStandardAuthVariables(parseResult.parsed, errors);
201
203
  }
202
204
 
205
+ if (normalizedType === 'datasource') {
206
+ const fieldRefErrors = validateFieldReferences(parseResult.parsed);
207
+ const abacErrors = validateAbac(parseResult.parsed);
208
+ errors.push(...fieldRefErrors, ...abacErrors);
209
+ }
210
+
203
211
  return {
204
212
  valid: errors.length === 0,
205
213
  errors,