@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
@@ -13,6 +13,7 @@ const path = require('path');
13
13
  const yaml = require('js-yaml');
14
14
  const os = require('os');
15
15
  const { encryptToken, decryptToken, isTokenEncrypted } = require('../utils/token-encryption');
16
+ const { ensureSecureFilePermissions, ensureSecureDirPermissions } = require('../utils/secure-file-permissions');
16
17
  // Avoid importing paths here to prevent circular dependency.
17
18
  // Config location (first match wins):
18
19
  // 1. AIFABRIX_CONFIG env = full path to config.yaml
@@ -111,7 +112,7 @@ function applyConfigDefaults(config) {
111
112
  config.device = {};
112
113
  }
113
114
  // Ensure controller field exists (but don't set defaults)
114
- // It will be set by login or auth config commands
115
+ // It will be set by login or auth --set-controller
115
116
  return config;
116
117
  }
117
118
 
@@ -133,6 +134,8 @@ function getDefaultConfig() {
133
134
 
134
135
  async function getConfig() {
135
136
  try {
137
+ ensureSecureDirPermissions(RUNTIME_CONFIG_DIR);
138
+ ensureSecureFilePermissions(RUNTIME_CONFIG_FILE);
136
139
  const configContent = await fs.readFile(RUNTIME_CONFIG_FILE, 'utf8');
137
140
  let config = yaml.load(configContent);
138
141
 
@@ -233,6 +236,7 @@ async function getDeveloperId() {
233
236
  */
234
237
  async function verifyDeveloperIdSaved(devIdString) {
235
238
  await new Promise(resolve => setTimeout(resolve, 100));
239
+ ensureSecureFilePermissions(RUNTIME_CONFIG_FILE);
236
240
  const savedContent = await fs.readFile(RUNTIME_CONFIG_FILE, 'utf8');
237
241
  const savedConfig = yaml.load(savedContent);
238
242
  const savedDevIdString = String(savedConfig['developer-id']);
@@ -427,11 +431,11 @@ async function getSecretsPath() {
427
431
  }
428
432
 
429
433
  async function setSecretsPath(secretsPath) {
430
- if (!secretsPath || typeof secretsPath !== 'string') {
434
+ if (typeof secretsPath !== 'string') {
431
435
  throw new Error('Secrets path is required and must be a string');
432
436
  }
433
437
  const config = await getConfig();
434
- config['aifabrix-secrets'] = secretsPath;
438
+ config['aifabrix-secrets'] = secretsPath.trim() || undefined;
435
439
  await saveConfig(config);
436
440
  }
437
441
 
@@ -489,10 +493,8 @@ Object.assign(exportsObj, tokenFunctions);
489
493
  const { createPathConfigFunctions } = require('../utils/config-paths');
490
494
  const pathConfigFunctions = createPathConfigFunctions(getConfig, saveConfig);
491
495
  Object.assign(exportsObj, pathConfigFunctions);
492
-
493
496
  // Format preference functions
494
497
  const { createFormatFunctions } = require('../utils/config-format-preference');
495
498
  const formatFunctions = createFormatFunctions(getConfig, saveConfig);
496
499
  Object.assign(exportsObj, formatFunctions);
497
-
498
500
  module.exports = exportsObj;
@@ -12,7 +12,6 @@ const fs = require('fs');
12
12
  const yaml = require('js-yaml');
13
13
  const crypto = require('crypto');
14
14
  const pathsUtil = require('../utils/paths');
15
- const { saveLocalSecret } = require('../utils/local-secrets');
16
15
 
17
16
  const ENCRYPTION_KEY = 'secrets-encryptionKeyVault';
18
17
 
@@ -30,7 +29,7 @@ function readKeyFromFile(filePath) {
30
29
 
31
30
  /**
32
31
  * Ensure secrets encryption key exists. If config already has it, do nothing.
33
- * If key exists in user or project secrets file, set config. Otherwise generate, write to user secrets, set config.
32
+ * If key exists in user or project secrets file, set config. Otherwise generate and store only in config (not in secrets file).
34
33
  * @param {Object} config - Config module (getSecretsEncryptionKey, setSecretsEncryptionKey, getSecretsPath)
35
34
  * @returns {Promise<void>}
36
35
  */
@@ -49,7 +48,6 @@ async function ensureSecretsEncryptionKey(config) {
49
48
  }
50
49
 
51
50
  const newKey = crypto.randomBytes(32).toString('hex');
52
- await saveLocalSecret(ENCRYPTION_KEY, newKey);
53
51
  await config.setSecretsEncryptionKey(newKey);
54
52
  }
55
53
 
@@ -52,6 +52,7 @@ const {
52
52
  } = require('../utils/secrets-utils');
53
53
  const { decryptSecret, isEncrypted } = require('../utils/secrets-encryption');
54
54
  const pathsUtil = require('../utils/paths');
55
+ const { ensureSecureFilePermissions } = require('../utils/secure-file-permissions');
55
56
 
56
57
  /**
57
58
  * Generates a canonical secret name from an environment variable key.
@@ -150,6 +151,7 @@ function mergeUserWithConfigFile(userSecrets, resolvedConfigPath) {
150
151
  if (!fs.existsSync(resolvedConfigPath)) {
151
152
  return null;
152
153
  }
154
+ ensureSecureFilePermissions(resolvedConfigPath);
153
155
  let configSecrets;
154
156
  try {
155
157
  configSecrets = readYamlAtPath(resolvedConfigPath);
@@ -225,6 +227,7 @@ async function loadSecretsWithFallbacks() {
225
227
  if (projectRoot) {
226
228
  const builderPath = path.join(projectRoot, 'builder', 'secrets.local.yaml');
227
229
  if (fs.existsSync(builderPath)) {
230
+ ensureSecureFilePermissions(builderPath);
228
231
  const builderSecrets = mergeUserWithConfigFile(merged || {}, builderPath);
229
232
  if (builderSecrets) merged = builderSecrets;
230
233
  }
@@ -244,6 +247,7 @@ async function loadSecrets(secretsPath, _appName) {
244
247
  if (!fs.existsSync(resolvedPath)) {
245
248
  throw new Error(`Secrets file not found: ${resolvedPath}`);
246
249
  }
250
+ ensureSecureFilePermissions(resolvedPath);
247
251
  const explicitSecrets = readYamlAtPath(resolvedPath);
248
252
  if (!explicitSecrets || typeof explicitSecrets !== 'object') {
249
253
  throw new Error(`Invalid secrets file format: ${resolvedPath}`);
@@ -516,6 +520,24 @@ async function generateEnvFile(appName, secretsPath, environment = 'local', forc
516
520
  return envPath;
517
521
  }
518
522
 
523
+ /**
524
+ * Writes admin env key-value pairs to content; encrypts values when encryption key is set.
525
+ * @async
526
+ * @param {Object.<string, string>} adminObj - Key-value object (e.g. POSTGRES_PASSWORD, ...)
527
+ * @returns {Promise<string>} .env-style content (plaintext or secure:// for secrets)
528
+ */
529
+ async function formatAdminSecretsContent(adminObj) {
530
+ const encryptionKey = await config.getSecretsEncryptionKey();
531
+ const { encryptSecret } = require('../utils/secrets-encryption');
532
+ const lines = ['# Infrastructure Admin Credentials'];
533
+ for (const [k, v] of Object.entries(adminObj)) {
534
+ const value = (v === null || v === undefined) ? '' : String(v).replace(/\n/g, ' ').trim();
535
+ const valueToWrite = encryptionKey ? encryptSecret(value, encryptionKey) : value;
536
+ lines.push(`${k}=${valueToWrite}`);
537
+ }
538
+ return lines.join('\n');
539
+ }
540
+
519
541
  /** Generates admin secrets for infrastructure (~/.aifabrix/admin-secrets.env). Uses admin123 when no postgres password. */
520
542
  async function generateAdminSecretsEnv(secretsPath) {
521
543
  let secrets;
@@ -541,15 +563,15 @@ async function generateAdminSecretsEnv(secretsPath) {
541
563
  const raw = secrets['postgres-passwordKeyVault'];
542
564
  const postgresPassword = (raw && String(raw).trim()) || 'admin123';
543
565
 
544
- const adminSecrets = `# Infrastructure Admin Credentials
545
- POSTGRES_PASSWORD=${postgresPassword}
546
- PGADMIN_DEFAULT_EMAIL=admin@aifabrix.dev
547
- PGADMIN_DEFAULT_PASSWORD=${postgresPassword}
548
- REDIS_HOST=local:redis:6379:0:
549
- REDIS_COMMANDER_USER=admin
550
- REDIS_COMMANDER_PASSWORD=${postgresPassword}
551
- `;
552
-
566
+ const adminObj = {
567
+ POSTGRES_PASSWORD: postgresPassword,
568
+ PGADMIN_DEFAULT_EMAIL: 'admin@aifabrix.dev',
569
+ PGADMIN_DEFAULT_PASSWORD: postgresPassword,
570
+ REDIS_HOST: 'local:redis:6379:0:',
571
+ REDIS_COMMANDER_USER: 'admin',
572
+ REDIS_COMMANDER_PASSWORD: postgresPassword
573
+ };
574
+ const adminSecrets = await formatAdminSecretsContent(adminObj);
553
575
  fs.writeFileSync(adminEnvPath, adminSecrets, { mode: 0o600 });
554
576
  return adminEnvPath;
555
577
  }
@@ -560,6 +582,7 @@ module.exports = {
560
582
  generateEnvContent,
561
583
  generateMissingSecrets,
562
584
  generateAdminSecretsEnv,
585
+ formatAdminSecretsContent,
563
586
  validateSecrets,
564
587
  createDefaultSecrets,
565
588
  getCanonicalSecretName,
@@ -268,7 +268,7 @@ function generateSecretsYaml(config, existingSecrets = {}) {
268
268
  Object.entries(existingSecrets).forEach(([key, value]) => {
269
269
  secrets.data[key] = Buffer.from(value).toString('base64');
270
270
  });
271
- return yaml.dump(secrets, { indent: 2, lineWidth: 120, noRefs: true, sortKeys: false });
271
+ return yaml.dump(secrets, { indent: 2, lineWidth: -1, noRefs: true, sortKeys: false });
272
272
  }
273
273
 
274
274
  module.exports = {
@@ -0,0 +1,157 @@
1
+ /**
2
+ * ABAC (Attribute-Based Access Control) validator for external datasources.
3
+ *
4
+ * Validates config.abac.dimensions (dimension-to-attribute references),
5
+ * config.abac.crossSystemJson (allowed operators, one per path, value types),
6
+ * and errors on legacy config.abac.crossSystem.
7
+ *
8
+ * @fileoverview ABAC validation for AI Fabrix Builder
9
+ * @author AI Fabrix Team
10
+ * @version 2.0.0
11
+ */
12
+
13
+ const DIMENSION_KEY_PATTERN = /^[a-zA-Z0-9_]+$/;
14
+ const ATTRIBUTE_PATH_PATTERN = /^[a-zA-Z0-9_.]+$/;
15
+ const CROSS_SYSTEM_JSON_PATH_PATTERN = /^[a-zA-Z0-9_.]+$/;
16
+ const ALLOWED_CROSS_SYSTEM_OPERATORS = new Set([
17
+ 'eq', 'ne', 'gt', 'lt', 'gte', 'lte', 'in', 'nin', 'contains', 'like', 'isNull', 'isNotNull'
18
+ ]);
19
+
20
+ /**
21
+ * Validates dimension keys and attribute path values for a dimensions object.
22
+ *
23
+ * @param {Object} dimensions - Object mapping dimension keys to attribute paths
24
+ * @param {string} source - Label for error messages (e.g. "config.abac.dimensions")
25
+ * @param {Set<string>} validAttributeNames - Set of valid attribute names (from fieldMappings.attributes)
26
+ * @returns {string[]} Error messages
27
+ */
28
+ function validateDimensionsObject(dimensions, source, validAttributeNames) {
29
+ const errors = [];
30
+ if (!dimensions || typeof dimensions !== 'object' || Array.isArray(dimensions)) {
31
+ return errors;
32
+ }
33
+ for (const [dimKey, attrPath] of Object.entries(dimensions)) {
34
+ if (!DIMENSION_KEY_PATTERN.test(dimKey)) {
35
+ errors.push(
36
+ `${source}: dimension key '${dimKey}' must contain only letters, numbers, and underscores. Add '${dimKey}' to fieldMappings.attributes or fix the key.`
37
+ );
38
+ }
39
+ if (typeof attrPath !== 'string' || !ATTRIBUTE_PATH_PATTERN.test(attrPath)) {
40
+ errors.push(
41
+ `${source}: attribute path for dimension '${dimKey}' must be a string with letters, numbers, underscores, and dots only.`
42
+ );
43
+ } else if (validAttributeNames && validAttributeNames.size > 0) {
44
+ const normalizedName = attrPath.includes('.') ? attrPath.split('.').pop() : attrPath;
45
+ if (!validAttributeNames.has(attrPath) && !validAttributeNames.has(normalizedName)) {
46
+ errors.push(
47
+ `${source}: dimension '${dimKey}' maps to '${attrPath}' which is not in fieldMappings.attributes. Add the attribute or remove from dimensions.`
48
+ );
49
+ }
50
+ }
51
+ }
52
+ return errors;
53
+ }
54
+
55
+ /**
56
+ * Validates crossSystemJson: path format, exactly one operator per path, allowed operators and value types.
57
+ *
58
+ * @param {Object} crossSystemJson - Object mapping field paths to operator objects
59
+ * @returns {string[]} Error messages
60
+ */
61
+ function validateCrossSystemJson(crossSystemJson) {
62
+ const errors = [];
63
+ if (!crossSystemJson || typeof crossSystemJson !== 'object' || Array.isArray(crossSystemJson)) {
64
+ return errors;
65
+ }
66
+ for (const [path, opObj] of Object.entries(crossSystemJson)) {
67
+ if (!CROSS_SYSTEM_JSON_PATH_PATTERN.test(path)) {
68
+ errors.push(
69
+ `config.abac.crossSystemJson: path '${path}' must contain only letters, numbers, underscores, and dots.`
70
+ );
71
+ continue;
72
+ }
73
+ if (typeof opObj !== 'object' || opObj === null || Array.isArray(opObj)) {
74
+ errors.push(
75
+ `config.abac.crossSystemJson.${path}: value must be an object with exactly one operator (e.g. { "eq": "user.country" }).`
76
+ );
77
+ continue;
78
+ }
79
+ const keys = Object.keys(opObj);
80
+ if (keys.length === 0) {
81
+ errors.push(
82
+ `config.abac.crossSystemJson.${path}: object must have exactly one operator. Allowed: ${[...ALLOWED_CROSS_SYSTEM_OPERATORS].join(', ')}.`
83
+ );
84
+ } else if (keys.length > 1) {
85
+ errors.push(
86
+ `config.abac.crossSystemJson.${path}: must have exactly one operator per path, got ${keys.join(', ')}. Use one of: ${[...ALLOWED_CROSS_SYSTEM_OPERATORS].join(', ')}.`
87
+ );
88
+ } else {
89
+ const op = keys[0];
90
+ if (!ALLOWED_CROSS_SYSTEM_OPERATORS.has(op)) {
91
+ errors.push(
92
+ `config.abac.crossSystemJson.${path}: unknown operator '${op}'. Allowed: ${[...ALLOWED_CROSS_SYSTEM_OPERATORS].join(', ')}.`
93
+ );
94
+ }
95
+ }
96
+ }
97
+ return errors;
98
+ }
99
+
100
+ /**
101
+ * Validates ABAC configuration for a parsed datasource.
102
+ * Checks dimensions (from config.abac or fieldMappings), crossSystemJson, and rejects legacy crossSystem.
103
+ *
104
+ * @function validateAbac
105
+ * @param {Object} parsed - Parsed datasource object (after JSON parse)
106
+ * @returns {string[]} Array of error messages; empty if valid
107
+ *
108
+ * @example
109
+ * const errors = validateAbac(parsed);
110
+ * if (errors.length > 0) errors.forEach(e => console.error(e));
111
+ */
112
+ function validateAbac(parsed) {
113
+ const errors = [];
114
+ const abac = parsed?.config?.abac;
115
+ if (!abac || typeof abac !== 'object') {
116
+ return errors;
117
+ }
118
+
119
+ if ('crossSystem' in abac) {
120
+ errors.push(
121
+ 'config.abac.crossSystem is deprecated. Use config.abac.crossSystemJson or config.abac.crossSystemSql instead.'
122
+ );
123
+ }
124
+
125
+ const attributeNames = new Set(
126
+ Object.keys(parsed?.fieldMappings?.attributes ?? {})
127
+ );
128
+
129
+ if (abac.dimensions) {
130
+ errors.push(...validateDimensionsObject(
131
+ abac.dimensions,
132
+ 'config.abac.dimensions',
133
+ attributeNames
134
+ ));
135
+ }
136
+
137
+ const fieldMappingsDimensions = parsed?.fieldMappings?.dimensions;
138
+ if (fieldMappingsDimensions && typeof fieldMappingsDimensions === 'object') {
139
+ errors.push(...validateDimensionsObject(
140
+ fieldMappingsDimensions,
141
+ 'fieldMappings.dimensions',
142
+ attributeNames
143
+ ));
144
+ }
145
+
146
+ if (abac.crossSystemJson) {
147
+ errors.push(...validateCrossSystemJson(abac.crossSystemJson));
148
+ }
149
+
150
+ return errors;
151
+ }
152
+
153
+ module.exports = {
154
+ validateAbac,
155
+ validateDimensionsObject,
156
+ validateCrossSystemJson
157
+ };
@@ -11,78 +11,116 @@
11
11
  */
12
12
 
13
13
  /**
14
- * Validates that all field references in indexing, validation, and quality
15
- * exist in fieldMappings.attributes. When fieldMappings.attributes is missing
16
- * or empty, returns no errors (skip check, matching dataplane behavior).
14
+ * Set of attribute names plus dimension keys valid for field references.
15
+ * Used for primaryKey (schema allows dimensions or attributes) and other paths (attributes only).
17
16
  *
18
- * @function validateFieldReferences
19
- * @param {Object} parsed - Parsed datasource object (after JSON parse)
20
- * @returns {string[]} Array of error messages; empty if no invalid references
21
- *
22
- * @example
23
- * const errors = validateFieldReferences(parsed);
24
- * if (errors.length > 0) {
25
- * errors.forEach(e => console.error(e));
26
- * }
17
+ * @param {Object} parsed - Parsed datasource object
18
+ * @returns {{ attributes: string[], attributesAndDimensions: Set<string> }}
27
19
  */
28
- function validateFieldReferences(parsed) {
29
- const errors = [];
30
- const normalizedAttributes = Object.keys(
31
- parsed?.fieldMappings?.attributes ?? {}
32
- );
20
+ function getNormalizedSets(parsed) {
21
+ const attributes = Object.keys(parsed?.fieldMappings?.attributes ?? {});
22
+ const dimensions = Object.keys(parsed?.fieldMappings?.dimensions ?? {});
23
+ const attributesAndDimensions = new Set([...attributes, ...dimensions]);
24
+ return { attributes, attributesAndDimensions };
25
+ }
33
26
 
34
- if (normalizedAttributes.length === 0) {
35
- return [];
36
- }
27
+ /** @param {string[]} errors */
28
+ function checkPrimaryKey(parsed, attributesAndDimensions, errors) {
29
+ const primaryKey = parsed?.primaryKey;
30
+ if (!Array.isArray(primaryKey)) return;
31
+ primaryKey.forEach((field, i) => {
32
+ if (typeof field === 'string' && field !== '' && !attributesAndDimensions.has(field)) {
33
+ errors.push(
34
+ `primaryKey[${i}]: field '${field}' does not exist in fieldMappings.attributes or fieldMappings.dimensions. Each primaryKey value must reference an attribute or dimension name.`
35
+ );
36
+ }
37
+ });
38
+ }
39
+
40
+ /** @param {string[]} errors */
41
+ function checkExposedProfiles(parsed, attrSet, errors) {
42
+ const profiles = parsed?.exposed?.profiles;
43
+ if (!profiles || typeof profiles !== 'object' || Array.isArray(profiles)) return;
44
+ Object.entries(profiles).forEach(([profileName, fields]) => {
45
+ if (!Array.isArray(fields)) return;
46
+ fields.forEach((field, idx) => {
47
+ if (typeof field === 'string' && !attrSet.has(field)) {
48
+ errors.push(
49
+ `exposed.profiles.${profileName}[${idx}]: field '${field}' does not exist in fieldMappings.attributes. Add the attribute or remove the reference.`
50
+ );
51
+ }
52
+ });
53
+ });
54
+ }
37
55
 
38
- // indexing.embedding: array of field names
56
+ /** @param {string[]} errors */
57
+ function checkIndexingAndValidation(parsed, normalizedAttributes, errors) {
39
58
  const embedding = parsed?.indexing?.embedding;
40
59
  if (Array.isArray(embedding)) {
41
60
  embedding.forEach((field, i) => {
42
61
  if (typeof field === 'string' && !normalizedAttributes.includes(field)) {
43
62
  errors.push(
44
- `indexing.embedding[${i}]: field '${field}' does not exist in fieldMappings.attributes`
63
+ `indexing.embedding[${i}]: field '${field}' does not exist in fieldMappings.attributes. Add the attribute or remove the reference.`
45
64
  );
46
65
  }
47
66
  });
48
67
  }
49
-
50
- // indexing.uniqueKey: single field name
51
68
  const uniqueKey = parsed?.indexing?.uniqueKey;
52
- if (typeof uniqueKey === 'string' && uniqueKey !== '') {
53
- if (!normalizedAttributes.includes(uniqueKey)) {
54
- errors.push(
55
- `indexing.uniqueKey: field '${uniqueKey}' does not exist in fieldMappings.attributes`
56
- );
57
- }
69
+ if (typeof uniqueKey === 'string' && uniqueKey !== '' && !normalizedAttributes.includes(uniqueKey)) {
70
+ errors.push(
71
+ `indexing.uniqueKey: field '${uniqueKey}' does not exist in fieldMappings.attributes. Add the attribute or remove the reference.`
72
+ );
58
73
  }
59
-
60
- // validation.repeatingValues[].field
61
74
  const repeatingValues = parsed?.validation?.repeatingValues;
62
75
  if (Array.isArray(repeatingValues)) {
63
76
  repeatingValues.forEach((rule, index) => {
64
77
  const field = rule?.field;
65
78
  if (typeof field === 'string' && !normalizedAttributes.includes(field)) {
66
79
  errors.push(
67
- `validation.repeatingValues[${index}].field: field '${field}' does not exist in fieldMappings.attributes`
80
+ `validation.repeatingValues[${index}].field: field '${field}' does not exist in fieldMappings.attributes. Add the attribute or remove the reference.`
68
81
  );
69
82
  }
70
83
  });
71
84
  }
72
-
73
- // quality.rejectIf[].field
74
85
  const rejectIf = parsed?.quality?.rejectIf;
75
86
  if (Array.isArray(rejectIf)) {
76
87
  rejectIf.forEach((rule, index) => {
77
88
  const field = rule?.field;
78
89
  if (typeof field === 'string' && !normalizedAttributes.includes(field)) {
79
90
  errors.push(
80
- `quality.rejectIf[${index}].field: field '${field}' does not exist in fieldMappings.attributes`
91
+ `quality.rejectIf[${index}].field: field '${field}' does not exist in fieldMappings.attributes. Add the attribute or remove the reference.`
81
92
  );
82
93
  }
83
94
  });
84
95
  }
96
+ }
97
+
98
+ /**
99
+ * Validates that all field references in indexing, validation, quality,
100
+ * primaryKey, and exposed.profiles exist in fieldMappings.attributes (or
101
+ * dimensions for primaryKey per schema). When fieldMappings.attributes is
102
+ * missing or empty, returns no errors (skip check, matching dataplane behavior).
103
+ *
104
+ * @function validateFieldReferences
105
+ * @param {Object} parsed - Parsed datasource object (after JSON parse)
106
+ * @returns {string[]} Array of error messages; empty if no invalid references
107
+ *
108
+ * @example
109
+ * const errors = validateFieldReferences(parsed);
110
+ * if (errors.length > 0) {
111
+ * errors.forEach(e => console.error(e));
112
+ * }
113
+ */
114
+ function validateFieldReferences(parsed) {
115
+ const errors = [];
116
+ const { attributes: normalizedAttributes, attributesAndDimensions } = getNormalizedSets(parsed);
117
+ const attrSet = new Set(normalizedAttributes);
118
+
119
+ checkPrimaryKey(parsed, attributesAndDimensions, errors);
120
+ checkExposedProfiles(parsed, attrSet, errors);
121
+ if (normalizedAttributes.length === 0) return errors;
85
122
 
123
+ checkIndexingAndValidation(parsed, normalizedAttributes, errors);
86
124
  return errors;
87
125
  }
88
126