@aifabrix/builder 2.40.2 → 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.
- package/README.md +6 -4
- package/integration/hubspot/test.js +1 -1
- package/lib/api/credential.api.js +40 -0
- package/lib/api/dev.api.js +423 -0
- package/lib/api/types/credential.types.js +23 -0
- package/lib/api/types/dev.types.js +140 -0
- package/lib/app/config.js +21 -0
- package/lib/app/down.js +2 -1
- package/lib/app/index.js +9 -0
- package/lib/app/push.js +36 -12
- package/lib/app/readme.js +1 -3
- package/lib/app/run-env-compose.js +201 -0
- package/lib/app/run-helpers.js +121 -118
- package/lib/app/run.js +148 -28
- package/lib/app/show.js +5 -2
- package/lib/build/index.js +11 -3
- package/lib/cli/setup-app.js +140 -14
- package/lib/cli/setup-dev.js +180 -17
- package/lib/cli/setup-environment.js +4 -2
- package/lib/cli/setup-external-system.js +71 -21
- package/lib/cli/setup-infra.js +29 -2
- package/lib/cli/setup-secrets.js +52 -5
- package/lib/cli/setup-utility.js +12 -3
- package/lib/commands/app-install.js +172 -0
- package/lib/commands/app-shell.js +75 -0
- package/lib/commands/app-test.js +282 -0
- package/lib/commands/app.js +1 -1
- package/lib/commands/dev-cli-handlers.js +141 -0
- package/lib/commands/dev-down.js +114 -0
- package/lib/commands/dev-init.js +309 -0
- package/lib/commands/secrets-list.js +118 -0
- package/lib/commands/secrets-remove.js +97 -0
- package/lib/commands/secrets-set.js +30 -17
- package/lib/commands/secrets-validate.js +50 -0
- package/lib/commands/up-dataplane.js +2 -2
- package/lib/commands/up-miso.js +0 -25
- package/lib/commands/upload.js +26 -1
- package/lib/core/admin-secrets.js +96 -0
- package/lib/core/secrets-ensure.js +378 -0
- package/lib/core/secrets-env-write.js +157 -0
- package/lib/core/secrets.js +147 -81
- package/lib/datasource/field-reference-validator.js +91 -0
- package/lib/datasource/validate.js +21 -3
- package/lib/deployment/environment-config.js +137 -0
- package/lib/deployment/environment.js +21 -98
- package/lib/deployment/push.js +32 -2
- package/lib/external-system/download.js +7 -0
- package/lib/external-system/test-auth.js +7 -3
- package/lib/external-system/test.js +5 -1
- package/lib/generator/index.js +174 -25
- package/lib/generator/wizard.js +8 -0
- package/lib/infrastructure/helpers.js +103 -20
- package/lib/infrastructure/index.js +88 -10
- package/lib/infrastructure/services.js +70 -15
- package/lib/schema/application-schema.json +24 -3
- package/lib/schema/external-system.schema.json +435 -413
- package/lib/utils/api.js +3 -3
- package/lib/utils/app-register-auth.js +25 -3
- package/lib/utils/cli-utils.js +20 -0
- package/lib/utils/compose-generator.js +76 -75
- package/lib/utils/compose-handlebars-helpers.js +43 -0
- package/lib/utils/compose-vector-helper.js +18 -0
- package/lib/utils/config-paths.js +127 -2
- package/lib/utils/credential-secrets-env.js +267 -0
- package/lib/utils/dev-cert-helper.js +122 -0
- package/lib/utils/device-code-helpers.js +224 -0
- package/lib/utils/device-code.js +37 -336
- package/lib/utils/docker-build.js +40 -8
- package/lib/utils/env-copy.js +83 -13
- package/lib/utils/env-map.js +35 -5
- package/lib/utils/env-template.js +6 -5
- package/lib/utils/error-formatters/http-status-errors.js +20 -1
- package/lib/utils/help-builder.js +15 -2
- package/lib/utils/infra-status.js +30 -1
- package/lib/utils/local-secrets.js +7 -52
- package/lib/utils/mutagen-install.js +195 -0
- package/lib/utils/mutagen.js +146 -0
- package/lib/utils/paths.js +43 -33
- package/lib/utils/port-resolver.js +28 -16
- package/lib/utils/remote-dev-auth.js +38 -0
- package/lib/utils/remote-docker-env.js +43 -0
- package/lib/utils/remote-secrets-loader.js +60 -0
- package/lib/utils/secrets-generator.js +94 -6
- package/lib/utils/secrets-helpers.js +33 -25
- package/lib/utils/secrets-path.js +2 -2
- package/lib/utils/secrets-utils.js +52 -1
- package/lib/utils/secrets-validation.js +84 -0
- package/lib/utils/ssh-key-helper.js +116 -0
- package/lib/utils/token-manager-messages.js +90 -0
- package/lib/utils/token-manager.js +5 -4
- package/lib/utils/variable-transformer.js +3 -3
- package/lib/validation/validator.js +65 -0
- package/package.json +2 -2
- package/scripts/install-local.js +34 -15
- package/templates/README.md +0 -1
- package/templates/applications/README.md.hbs +4 -4
- package/templates/applications/dataplane/application.yaml +5 -4
- package/templates/applications/dataplane/env.template +12 -7
- package/templates/applications/keycloak/env.template +2 -0
- package/templates/applications/miso-controller/application.yaml +1 -0
- package/templates/applications/miso-controller/env.template +11 -9
- package/templates/python/docker-compose.hbs +49 -23
- 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
|
+
};
|