@aifabrix/builder 2.40.0 → 2.41.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 (108) hide show
  1. package/README.md +7 -5
  2. package/integration/hubspot/test.js +1 -1
  3. package/jest.config.manual.js +29 -0
  4. package/lib/api/credential.api.js +40 -0
  5. package/lib/api/dev.api.js +423 -0
  6. package/lib/api/types/credential.types.js +23 -0
  7. package/lib/api/types/dev.types.js +140 -0
  8. package/lib/app/config.js +21 -0
  9. package/lib/app/down.js +2 -1
  10. package/lib/app/index.js +9 -0
  11. package/lib/app/push.js +36 -12
  12. package/lib/app/readme.js +1 -3
  13. package/lib/app/run-env-compose.js +201 -0
  14. package/lib/app/run-helpers.js +121 -118
  15. package/lib/app/run.js +148 -28
  16. package/lib/app/show.js +5 -2
  17. package/lib/build/index.js +11 -3
  18. package/lib/cli/setup-app.js +140 -14
  19. package/lib/cli/setup-auth.js +1 -0
  20. package/lib/cli/setup-dev.js +180 -17
  21. package/lib/cli/setup-environment.js +4 -2
  22. package/lib/cli/setup-external-system.js +71 -21
  23. package/lib/cli/setup-infra.js +29 -2
  24. package/lib/cli/setup-secrets.js +52 -5
  25. package/lib/cli/setup-utility.js +19 -4
  26. package/lib/commands/app-install.js +172 -0
  27. package/lib/commands/app-shell.js +75 -0
  28. package/lib/commands/app-test.js +282 -0
  29. package/lib/commands/app.js +1 -1
  30. package/lib/commands/auth-status.js +36 -3
  31. package/lib/commands/dev-cli-handlers.js +141 -0
  32. package/lib/commands/dev-down.js +114 -0
  33. package/lib/commands/dev-init.js +309 -0
  34. package/lib/commands/secrets-list.js +118 -0
  35. package/lib/commands/secrets-remove.js +97 -0
  36. package/lib/commands/secrets-set.js +30 -17
  37. package/lib/commands/secrets-validate.js +50 -0
  38. package/lib/commands/up-dataplane.js +2 -2
  39. package/lib/commands/up-miso.js +0 -25
  40. package/lib/commands/upload.js +26 -1
  41. package/lib/core/admin-secrets.js +96 -0
  42. package/lib/core/secrets-ensure.js +378 -0
  43. package/lib/core/secrets-env-write.js +157 -0
  44. package/lib/core/secrets.js +147 -81
  45. package/lib/datasource/field-reference-validator.js +91 -0
  46. package/lib/datasource/validate.js +21 -3
  47. package/lib/deployment/environment-config.js +137 -0
  48. package/lib/deployment/environment.js +21 -98
  49. package/lib/deployment/push.js +32 -2
  50. package/lib/external-system/download.js +7 -0
  51. package/lib/external-system/test-auth.js +7 -3
  52. package/lib/external-system/test.js +5 -1
  53. package/lib/generator/index.js +174 -25
  54. package/lib/generator/wizard.js +13 -1
  55. package/lib/infrastructure/helpers.js +103 -20
  56. package/lib/infrastructure/index.js +88 -10
  57. package/lib/infrastructure/services.js +70 -15
  58. package/lib/schema/application-schema.json +24 -3
  59. package/lib/schema/external-system.schema.json +435 -413
  60. package/lib/utils/api.js +3 -3
  61. package/lib/utils/app-register-auth.js +25 -3
  62. package/lib/utils/cli-utils.js +20 -0
  63. package/lib/utils/compose-generator.js +76 -75
  64. package/lib/utils/compose-handlebars-helpers.js +43 -0
  65. package/lib/utils/compose-vector-helper.js +18 -0
  66. package/lib/utils/config-paths.js +127 -2
  67. package/lib/utils/credential-secrets-env.js +267 -0
  68. package/lib/utils/dev-cert-helper.js +122 -0
  69. package/lib/utils/device-code-helpers.js +224 -0
  70. package/lib/utils/device-code.js +37 -336
  71. package/lib/utils/docker-build.js +40 -8
  72. package/lib/utils/env-copy.js +83 -13
  73. package/lib/utils/env-map.js +35 -5
  74. package/lib/utils/env-template.js +6 -5
  75. package/lib/utils/error-formatters/http-status-errors.js +20 -1
  76. package/lib/utils/help-builder.js +15 -2
  77. package/lib/utils/infra-status.js +30 -1
  78. package/lib/utils/local-secrets.js +7 -52
  79. package/lib/utils/mutagen-install.js +195 -0
  80. package/lib/utils/mutagen.js +146 -0
  81. package/lib/utils/paths.js +49 -33
  82. package/lib/utils/port-resolver.js +28 -16
  83. package/lib/utils/remote-dev-auth.js +38 -0
  84. package/lib/utils/remote-docker-env.js +43 -0
  85. package/lib/utils/remote-secrets-loader.js +60 -0
  86. package/lib/utils/secrets-generator.js +94 -6
  87. package/lib/utils/secrets-helpers.js +33 -25
  88. package/lib/utils/secrets-path.js +2 -2
  89. package/lib/utils/secrets-utils.js +52 -1
  90. package/lib/utils/secrets-validation.js +84 -0
  91. package/lib/utils/ssh-key-helper.js +116 -0
  92. package/lib/utils/token-manager-messages.js +90 -0
  93. package/lib/utils/token-manager.js +5 -4
  94. package/lib/utils/variable-transformer.js +3 -3
  95. package/lib/validation/validate.js +1 -1
  96. package/lib/validation/validator.js +65 -0
  97. package/package.json +4 -2
  98. package/scripts/install-local.js +34 -15
  99. package/templates/README.md +0 -1
  100. package/templates/applications/README.md.hbs +4 -4
  101. package/templates/applications/dataplane/application.yaml +5 -4
  102. package/templates/applications/dataplane/env.template +12 -7
  103. package/templates/applications/keycloak/env.template +2 -0
  104. package/templates/applications/miso-controller/application.yaml +1 -0
  105. package/templates/applications/miso-controller/env.template +11 -9
  106. package/templates/external-system/external-system.json.hbs +1 -16
  107. package/templates/python/docker-compose.hbs +49 -23
  108. package/templates/typescript/docker-compose.hbs +48 -22
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Collect KV_* env vars as secret store items and push to dataplane.
3
+ * Reads integration .env, resolves kv:// in values, scans payload for kv:// refs,
4
+ * and pushes plain values to POST /api/v1/credential/secret.
5
+ *
6
+ * @fileoverview Credential secrets push from .env and payload (Dataplane)
7
+ * @author AI Fabrix Team
8
+ * @version 2.0.0
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const { loadSecrets } = require('../core/secrets');
13
+ const { storeCredentialSecrets } = require('../api/credential.api');
14
+
15
+ const KV_PREFIX = 'KV_';
16
+ const KV_REF_PATTERN = /kv:\/\/([a-zA-Z0-9_\-/]+)/g;
17
+
18
+ /**
19
+ * Converts KV_* env key to kv:// path (e.g. KV_SECRETS_FOO → kv://secrets/foo).
20
+ * @param {string} envKey - Env var name (e.g. KV_SECRETS_CLIENT_SECRET)
21
+ * @returns {string|null} kv:// path or null if invalid
22
+ */
23
+ function kvEnvKeyToPath(envKey) {
24
+ if (!envKey || typeof envKey !== 'string' || !envKey.toUpperCase().startsWith(KV_PREFIX)) {
25
+ return null;
26
+ }
27
+ const rest = envKey.slice(KV_PREFIX.length);
28
+ if (!rest) return null;
29
+ const segments = rest.split('_').filter(Boolean);
30
+ const pathPart = segments.map(s => s.toLowerCase()).join('/');
31
+ return pathPart ? `kv://${pathPart}` : null;
32
+ }
33
+
34
+ /**
35
+ * Collects KV_* entries from env map as secret items (key = kv path, value = raw).
36
+ * Does not resolve values; empty values are skipped.
37
+ *
38
+ * @param {Object.<string, string>} envMap - Key-value map from .env
39
+ * @returns {Array<{ key: string, value: string }>} Items (key = kv://..., value = raw)
40
+ */
41
+ function collectKvEnvVarsAsSecretItems(envMap) {
42
+ if (!envMap || typeof envMap !== 'object') {
43
+ return [];
44
+ }
45
+ const items = [];
46
+ for (const [envKey, rawValue] of Object.entries(envMap)) {
47
+ const value = typeof rawValue === 'string' ? rawValue.trim() : '';
48
+ if (value === '') continue;
49
+ const kvPath = kvEnvKeyToPath(envKey);
50
+ if (!kvPath) continue;
51
+ items.push({ key: kvPath, value });
52
+ }
53
+ return items;
54
+ }
55
+
56
+ /**
57
+ * Resolves a single value if it is a kv:// reference using secrets map.
58
+ * Supports path-style keys (e.g. secrets/foo) and hyphen-style (secrets-foo).
59
+ *
60
+ * @param {Object} secrets - Loaded secrets object
61
+ * @param {string} value - Value (may be "kv://..." or plain)
62
+ * @returns {string|null} Resolved plain value or null if unresolved
63
+ */
64
+ function resolveKvValue(secrets, value) {
65
+ if (typeof value !== 'string') return null;
66
+ const trimmed = value.trim();
67
+ if (!trimmed.startsWith('kv://')) {
68
+ return trimmed;
69
+ }
70
+ const pathMatch = trimmed.match(/^kv:\/\/([a-zA-Z0-9_\-/]+)$/);
71
+ if (!pathMatch) return null;
72
+ const pathKey = pathMatch[1];
73
+ let resolved = secrets[pathKey];
74
+ if (resolved === undefined && pathKey.includes('/')) {
75
+ resolved = secrets[pathKey.replace(/\//g, '-')];
76
+ }
77
+ if (resolved === undefined) return null;
78
+ return typeof resolved === 'string' ? resolved : String(resolved);
79
+ }
80
+
81
+ /**
82
+ * Recursively collects all kv:// references from a JSON-serializable payload.
83
+ *
84
+ * @param {Object|Array|string|number|boolean|null} obj - Upload payload (application + dataSources)
85
+ * @param {Set<string>} [acc] - Accumulator set (internal)
86
+ * @returns {string[]} Unique kv:// refs (e.g. ["kv://secrets/foo"])
87
+ */
88
+ function collectKvRefsFromPayload(obj, acc = new Set()) {
89
+ if (obj === null || obj === undefined) {
90
+ return Array.from(acc);
91
+ }
92
+ if (typeof obj === 'string') {
93
+ let m;
94
+ KV_REF_PATTERN.lastIndex = 0;
95
+ while ((m = KV_REF_PATTERN.exec(obj)) !== null) {
96
+ acc.add(`kv://${m[1]}`);
97
+ }
98
+ return Array.from(acc);
99
+ }
100
+ if (Array.isArray(obj)) {
101
+ for (const item of obj) {
102
+ collectKvRefsFromPayload(item, acc);
103
+ }
104
+ return Array.from(acc);
105
+ }
106
+ if (typeof obj === 'object') {
107
+ for (const v of Object.values(obj)) {
108
+ collectKvRefsFromPayload(v, acc);
109
+ }
110
+ return Array.from(acc);
111
+ }
112
+ return Array.from(acc);
113
+ }
114
+
115
+ /**
116
+ * Validates that key is a well-formed kv path (kv:// with alphanumeric/hyphen/slash segments).
117
+ *
118
+ * @param {string} key - kv:// path
119
+ * @returns {boolean}
120
+ */
121
+ function isValidKvPath(key) {
122
+ return typeof key === 'string' && /^kv:\/\/[a-z0-9][a-z0-9\-/]*$/i.test(key.trim());
123
+ }
124
+
125
+ /**
126
+ * Builds secret items from .env file (KV_* vars, values resolved).
127
+ * @param {string} envFilePath - Path to .env
128
+ * @param {Object} secrets - Loaded secrets
129
+ * @param {Map<string, string>} itemsByKey - Mutable map to add items to
130
+ */
131
+ function buildItemsFromEnv(envFilePath, secrets, itemsByKey) {
132
+ if (!envFilePath || typeof envFilePath !== 'string' || !fs.existsSync(envFilePath)) return;
133
+ try {
134
+ const content = fs.readFileSync(envFilePath, 'utf8');
135
+ const envMap = parseEnvToMap(content);
136
+ const fromEnv = collectKvEnvVarsAsSecretItems(envMap);
137
+ for (const { key, value } of fromEnv) {
138
+ const resolved = resolveKvValue(secrets, value);
139
+ if (resolved !== null && resolved !== undefined && isValidKvPath(key)) itemsByKey.set(key, resolved);
140
+ }
141
+ } catch {
142
+ // Best-effort: continue without .env items
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Builds secret items from payload kv:// refs not already in itemsByKey.
148
+ * @param {Object} payload - Upload payload
149
+ * @param {Object} secrets - Loaded secrets
150
+ * @param {Map<string, string>} itemsByKey - Mutable map to add items to
151
+ */
152
+ function buildItemsFromPayload(payload, secrets, itemsByKey) {
153
+ if (!payload || typeof payload !== 'object') return;
154
+ const refs = collectKvRefsFromPayload(payload);
155
+ const existingKeys = new Set(itemsByKey.keys());
156
+ for (const ref of refs) {
157
+ if (existingKeys.has(ref)) continue;
158
+ const pathKey = ref.replace(/^kv:\/\//, '');
159
+ let resolved = secrets[pathKey];
160
+ if (resolved === undefined && pathKey.includes('/')) {
161
+ resolved = secrets[pathKey.replace(/\//g, '-')];
162
+ }
163
+ if (resolved !== null && resolved !== undefined && isValidKvPath(ref)) {
164
+ itemsByKey.set(ref, typeof resolved === 'string' ? resolved : String(resolved));
165
+ }
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Derives stored count from API response.
171
+ * @param {Object} res - Response from storeCredentialSecrets
172
+ * @param {number} fallback - Fallback when stored not in response
173
+ * @returns {number}
174
+ */
175
+ function storedCountFromResponse(res, fallback) {
176
+ if (res && typeof res.stored === 'number') return res.stored;
177
+ if (res && res.data && res.data.stored !== undefined && res.data.stored !== null) return res.data.stored;
178
+ return fallback;
179
+ }
180
+
181
+ /**
182
+ * Sends items to dataplane credential API; returns pushed count or warning.
183
+ * @param {string} dataplaneUrl - Dataplane URL
184
+ * @param {Object} authConfig - Auth config
185
+ * @param {Array<{ key: string, value: string }>} items - Items to send
186
+ * @returns {Promise<{ pushed: number, warning?: string }>}
187
+ */
188
+ async function sendCredentialSecrets(dataplaneUrl, authConfig, items) {
189
+ try {
190
+ const res = await storeCredentialSecrets(dataplaneUrl, authConfig, items);
191
+ if (res && res.success === false) {
192
+ const status = res.status ?? res.statusCode;
193
+ if (status === 403 || status === 401) {
194
+ return { pushed: 0, warning: 'Could not push credential secrets (permission denied or unauthenticated). Ensure dataplane role has credential:create if you use KV_* in .env.' };
195
+ }
196
+ return { pushed: 0, warning: res.formattedError || res.error || 'Failed to push credential secrets to dataplane.' };
197
+ }
198
+ return { pushed: storedCountFromResponse(res, items.length) };
199
+ } catch (err) {
200
+ return { pushed: 0, warning: err.message || 'Failed to push credential secrets to dataplane.' };
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Pushes credential secrets to dataplane: from .env (KV_*) and from payload kv:// refs.
206
+ * Resolves all kv:// in values via loadSecrets; sends only plain values. Best-effort:
207
+ * on 403/401 logs warning and returns; never logs secret values.
208
+ *
209
+ * @param {string} dataplaneUrl - Dataplane base URL
210
+ * @param {Object} authConfig - Auth config (Bearer)
211
+ * @param {Object} options - Options
212
+ * @param {string} [options.envFilePath] - Path to .env (integration/<systemKey>/.env)
213
+ * @param {string} [options.appName] - App/system name for loadSecrets context
214
+ * @param {Object} [options.payload] - Upload payload { application, dataSources } for kv scan
215
+ * @returns {Promise<{ pushed: number, warning?: string }>} Count pushed and optional warning
216
+ */
217
+ async function pushCredentialSecrets(dataplaneUrl, authConfig, options = {}) {
218
+ const { envFilePath, appName, payload } = options;
219
+ let secrets;
220
+ try {
221
+ secrets = await loadSecrets(undefined, appName);
222
+ } catch {
223
+ secrets = {};
224
+ }
225
+ const itemsByKey = new Map();
226
+ buildItemsFromEnv(envFilePath, secrets, itemsByKey);
227
+ buildItemsFromPayload(payload, secrets, itemsByKey);
228
+
229
+ const items = Array.from(itemsByKey.entries())
230
+ .filter(([k]) => isValidKvPath(k))
231
+ .map(([key, value]) => ({ key, value }));
232
+
233
+ if (items.length === 0) return { pushed: 0 };
234
+ return sendCredentialSecrets(dataplaneUrl, authConfig, items);
235
+ }
236
+
237
+ /**
238
+ * Parses .env-style content into key-value map (first = separates key and value).
239
+ *
240
+ * @param {string} content - Raw .env content
241
+ * @returns {Object.<string, string>}
242
+ */
243
+ function parseEnvToMap(content) {
244
+ if (!content || typeof content !== 'string') return {};
245
+ const map = {};
246
+ const lines = content.split(/\r?\n/);
247
+ for (const line of lines) {
248
+ const trimmed = line.trim();
249
+ if (!trimmed || trimmed.startsWith('#')) continue;
250
+ const eq = trimmed.indexOf('=');
251
+ if (eq > 0) {
252
+ const key = trimmed.substring(0, eq).trim();
253
+ const value = trimmed.substring(eq + 1);
254
+ map[key] = value;
255
+ }
256
+ }
257
+ return map;
258
+ }
259
+
260
+ module.exports = {
261
+ collectKvEnvVarsAsSecretItems,
262
+ collectKvRefsFromPayload,
263
+ pushCredentialSecrets,
264
+ kvEnvKeyToPath,
265
+ isValidKvPath,
266
+ resolveKvValue
267
+ };
@@ -0,0 +1,122 @@
1
+ /**
2
+ * @fileoverview Helper for generating CSR and saving dev certificates (Builder Server onboarding)
3
+ * @author AI Fabrix Team
4
+ * @version 2.0.0
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { execSync } = require('child_process');
10
+ const os = require('os');
11
+
12
+ /**
13
+ * Generate a key pair and CSR for developer certificate. Uses OpenSSL when available.
14
+ * CN in CSR is set to dev-<developerId> per Builder Server convention.
15
+ * @param {string} developerId - Developer ID (e.g. "01")
16
+ * @returns {{ csrPem: string, keyPem: string }} PEM-encoded CSR and private key
17
+ * @throws {Error} If OpenSSL is not available or generation fails
18
+ */
19
+ function generateCSR(developerId) {
20
+ if (!developerId || typeof developerId !== 'string') {
21
+ throw new Error('developerId is required and must be a string');
22
+ }
23
+ const cn = `dev-${developerId}`;
24
+ const tmpDir = path.join(os.tmpdir(), `aifabrix-csr-${Date.now()}`);
25
+ fs.mkdirSync(tmpDir, { recursive: true });
26
+ const keyPath = path.join(tmpDir, 'key.pem');
27
+ const csrPath = path.join(tmpDir, 'csr.pem');
28
+ try {
29
+ execSync(
30
+ `openssl req -new -newkey rsa:2048 -keyout "${keyPath}" -nodes -subj "/CN=${cn}" -out "${csrPath}"`,
31
+ { stdio: 'pipe', encoding: 'utf8' }
32
+ );
33
+ const keyPem = fs.readFileSync(keyPath, 'utf8');
34
+ const csrPem = fs.readFileSync(csrPath, 'utf8');
35
+ return { csrPem, keyPem };
36
+ } catch (err) {
37
+ if (err.message && (err.message.includes('openssl') || err.message.includes('ENOENT'))) {
38
+ throw new Error(
39
+ 'OpenSSL is required for certificate generation. Install OpenSSL and ensure it is on PATH, or use a system that provides it (e.g. Git for Windows).'
40
+ );
41
+ }
42
+ throw new Error(`CSR generation failed: ${err.message}`);
43
+ } finally {
44
+ try {
45
+ fs.unlinkSync(keyPath);
46
+ fs.unlinkSync(csrPath);
47
+ fs.rmdirSync(tmpDir);
48
+ } catch {
49
+ // ignore cleanup errors
50
+ }
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Return path to the directory where dev certificates are stored for a developer.
56
+ * @param {string} configDir - Config directory (e.g. from getConfigDirForPaths())
57
+ * @param {string} developerId - Developer ID
58
+ * @returns {string} Absolute path to certs/<developerId>/
59
+ */
60
+ function getCertDir(configDir, developerId) {
61
+ return path.join(configDir, 'certs', developerId);
62
+ }
63
+
64
+ /**
65
+ * Read client certificate PEM from cert dir (cert.pem).
66
+ * @param {string} certDir - Directory containing cert.pem
67
+ * @returns {string|null} PEM content or null if not found
68
+ */
69
+ function readClientCertPem(certDir) {
70
+ const certPath = path.join(certDir, 'cert.pem');
71
+ try {
72
+ return fs.readFileSync(certPath, 'utf8');
73
+ } catch (e) {
74
+ if (e.code === 'ENOENT') return null;
75
+ throw e;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Read client private key PEM from cert dir (key.pem). Used for mTLS (e.g. getSettings).
81
+ * @param {string} certDir - Directory containing key.pem
82
+ * @returns {string|null} PEM content or null if not found
83
+ */
84
+ function readClientKeyPem(certDir) {
85
+ const keyPath = path.join(certDir, 'key.pem');
86
+ try {
87
+ return fs.readFileSync(keyPath, 'utf8');
88
+ } catch (e) {
89
+ if (e.code === 'ENOENT') return null;
90
+ throw e;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Get certificate validity end (notAfter) from cert.pem in certDir using OpenSSL.
96
+ * @param {string} certDir - Directory containing cert.pem
97
+ * @returns {Date|null} Expiry date or null if cert missing/invalid or OpenSSL fails
98
+ */
99
+ function getCertValidNotAfter(certDir) {
100
+ const certPath = path.join(certDir, 'cert.pem');
101
+ try {
102
+ if (!fs.existsSync(certPath)) return null;
103
+ const out = execSync(
104
+ `openssl x509 -enddate -noout -in "${certPath}"`,
105
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
106
+ );
107
+ const match = out.match(/notAfter=(.+)/);
108
+ if (!match) return null;
109
+ const date = new Date(match[1].trim());
110
+ return isNaN(date.getTime()) ? null : date;
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ module.exports = {
117
+ generateCSR,
118
+ getCertDir,
119
+ readClientCertPem,
120
+ readClientKeyPem,
121
+ getCertValidNotAfter
122
+ };
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Device code flow parsing, error handling, and polling helpers.
3
+ * Used by device-code.js; not part of the public API.
4
+ *
5
+ * @fileoverview Helpers for device code flow (RFC 8628)
6
+ * @author AI Fabrix Team
7
+ * @version 2.0.0
8
+ */
9
+
10
+ /**
11
+ * Parses device code response from API
12
+ * @param {Object} response - API response object
13
+ * @returns {Object} Parsed device code response
14
+ */
15
+ function parseDeviceCodeResponse(response) {
16
+ const apiResponse = response.data;
17
+ const responseData = apiResponse.data || apiResponse;
18
+ const deviceCode = responseData.deviceCode;
19
+ const userCode = responseData.userCode;
20
+ const verificationUri = responseData.verificationUri;
21
+ const expiresIn = responseData.expiresIn || 600;
22
+ const interval = responseData.interval || 5;
23
+
24
+ if (!deviceCode || !userCode || !verificationUri) {
25
+ throw new Error('Invalid device code response: missing required fields');
26
+ }
27
+
28
+ return {
29
+ device_code: deviceCode,
30
+ user_code: userCode,
31
+ verification_uri: verificationUri,
32
+ expires_in: expiresIn,
33
+ interval: interval
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Parses token response from API
39
+ * @param {Object} response - API response object
40
+ * @returns {Object|null} Parsed token response or null if pending
41
+ */
42
+ function parseTokenResponse(response) {
43
+ const apiResponse = response.data;
44
+ const responseData = apiResponse.data || apiResponse;
45
+ const error = responseData.error || apiResponse.error;
46
+ if (error === 'authorization_pending' || error === 'slow_down') {
47
+ return null;
48
+ }
49
+
50
+ const accessToken = responseData.accessToken;
51
+ const refreshToken = responseData.refreshToken;
52
+ const expiresIn = responseData.expiresIn || 3600;
53
+
54
+ if (!accessToken) {
55
+ throw new Error('Invalid token response: missing accessToken');
56
+ }
57
+
58
+ return {
59
+ access_token: accessToken,
60
+ refresh_token: refreshToken,
61
+ expires_in: expiresIn
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Checks if token has expired based on elapsed time
67
+ * @param {number} startTime - Start time in milliseconds
68
+ * @param {number} expiresIn - Expiration time in seconds
69
+ */
70
+ function checkTokenExpiration(startTime, expiresIn) {
71
+ const maxWaitTime = (expiresIn + 30) * 1000;
72
+ if (Date.now() - startTime > maxWaitTime) {
73
+ throw new Error('Device code expired: Maximum polling time exceeded');
74
+ }
75
+ }
76
+
77
+ function attachFormattedError(validationError, response) {
78
+ if (response && response.formattedError) {
79
+ validationError.formattedError = response.formattedError;
80
+ validationError.message = `Token polling failed:\n${response.formattedError}`;
81
+ }
82
+ }
83
+
84
+ function buildDetailedErrorMessage(errorData) {
85
+ const detail = errorData.detail || errorData.title || errorData.message || 'Validation error';
86
+ let errorMsg = `Token polling failed: ${detail}`;
87
+ if (errorData.errors && Array.isArray(errorData.errors) && errorData.errors.length > 0) {
88
+ errorMsg += '\n\nValidation errors:';
89
+ errorData.errors.forEach(err => {
90
+ const field = err.field || err.path || 'validation';
91
+ const message = err.message || 'Invalid value';
92
+ errorMsg += (field === 'validation' || field === 'unknown')
93
+ ? `\n • ${message}` : `\n • ${field}: ${message}`;
94
+ });
95
+ }
96
+ return errorMsg;
97
+ }
98
+
99
+ function attachErrorData(validationError, response) {
100
+ if (response && response.errorData) {
101
+ validationError.errorData = response.errorData;
102
+ validationError.errorType = response.errorType || 'validation';
103
+ if (!validationError.formattedError) {
104
+ validationError.message = buildDetailedErrorMessage(response.errorData);
105
+ }
106
+ }
107
+ }
108
+
109
+ function createValidationError(response) {
110
+ const validationError = new Error('Token polling failed: Validation error');
111
+ attachFormattedError(validationError, response);
112
+ attachErrorData(validationError, response);
113
+ return validationError;
114
+ }
115
+
116
+ /**
117
+ * Handles polling errors; throws for fatal errors, returns true to continue polling.
118
+ * @param {string} error - Error code
119
+ * @param {number} status - HTTP status code
120
+ * @param {Object} response - Full API response object
121
+ * @returns {boolean} True if should continue polling
122
+ */
123
+ function handlePollingErrors(error, status, response) {
124
+ if (error === 'authorization_pending' || status === 202) {
125
+ return true;
126
+ }
127
+ if (error === 'authorization_declined') {
128
+ throw new Error('Authorization declined: User denied the request');
129
+ }
130
+ if (error === 'expired_token' || status === 410) {
131
+ throw new Error('Device code expired: Please restart the authentication process');
132
+ }
133
+ if (error === 'slow_down') {
134
+ return true;
135
+ }
136
+ if (error === 'validation_error' || status === 400 ||
137
+ error === 'INVALID_TOKEN' || error === 'INVALID_ACCESS_TOKEN') {
138
+ throw createValidationError(response);
139
+ }
140
+ throw new Error(`Token polling failed: ${error}`);
141
+ }
142
+
143
+ async function waitForNextPoll(interval, slowDown) {
144
+ const waitInterval = slowDown ? interval * 2 : interval;
145
+ await new Promise(resolve => setTimeout(resolve, waitInterval * 1000));
146
+ }
147
+
148
+ function isValidationErrorCode(errorCode) {
149
+ return errorCode === 'INVALID_TOKEN' || errorCode === 'INVALID_ACCESS_TOKEN';
150
+ }
151
+
152
+ function extractStructuredError(response) {
153
+ if (response.errorType === 'validation') {
154
+ return 'validation_error';
155
+ }
156
+ const errorData = response.errorData;
157
+ const errorCode = errorData.error || errorData.code || response.error;
158
+ if (isValidationErrorCode(errorCode)) {
159
+ return 'validation_error';
160
+ }
161
+ return errorData.detail || errorData.title || errorData.message || errorCode || response.error || 'Unknown error';
162
+ }
163
+
164
+ function extractFallbackError(response) {
165
+ const apiResponse = response.data || {};
166
+ const errorData = typeof apiResponse === 'object' ? apiResponse : {};
167
+ const errorCode = errorData.error || response.error || 'Unknown error';
168
+ if (isValidationErrorCode(errorCode)) {
169
+ return 'validation_error';
170
+ }
171
+ return errorCode;
172
+ }
173
+
174
+ function extractPollingError(response) {
175
+ if (response.errorData) {
176
+ return extractStructuredError(response);
177
+ }
178
+ return extractFallbackError(response);
179
+ }
180
+
181
+ function handleSuccessfulPoll(response) {
182
+ const tokenResponse = parseTokenResponse(response);
183
+ return tokenResponse || null;
184
+ }
185
+
186
+ /**
187
+ * Processes polling response and determines next action.
188
+ * @param {Object} response - API response object
189
+ * @param {number} interval - Polling interval in seconds
190
+ * @returns {Promise<Object|null>} Token response if complete, null if should continue
191
+ */
192
+ async function processPollingResponse(response, interval) {
193
+ if (response.success) {
194
+ const apiResponse = response.data || {};
195
+ const responseData = apiResponse.data || apiResponse;
196
+ const errorCode = responseData.error || apiResponse.error || response.error;
197
+ if (errorCode && (errorCode === 'INVALID_TOKEN' || errorCode === 'INVALID_ACCESS_TOKEN')) {
198
+ throw createValidationError(response);
199
+ }
200
+ const tokenResponse = handleSuccessfulPoll(response);
201
+ if (tokenResponse) {
202
+ return tokenResponse;
203
+ }
204
+ const error = errorCode;
205
+ const slowDown = error === 'slow_down';
206
+ await waitForNextPoll(interval, slowDown);
207
+ return null;
208
+ }
209
+
210
+ const error = extractPollingError(response);
211
+ const shouldContinue = handlePollingErrors(error, response.status, response);
212
+ if (shouldContinue) {
213
+ await waitForNextPoll(interval, error === 'slow_down');
214
+ return null;
215
+ }
216
+ return null;
217
+ }
218
+
219
+ module.exports = {
220
+ parseDeviceCodeResponse,
221
+ parseTokenResponse,
222
+ checkTokenExpiration,
223
+ processPollingResponse
224
+ };