-
Notifications
You must be signed in to change notification settings - Fork 11
Expand file tree
/
Copy pathmapping.ts
More file actions
498 lines (473 loc) · 16.2 KB
/
mapping.ts
File metadata and controls
498 lines (473 loc) · 16.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
/*
* Copyright (c) 2025, Salesforce, Inc.
* SPDX-License-Identifier: Apache-2
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
*/
/**
* Configuration mapping utilities.
*
* This module provides the single source of truth for mapping between
* different configuration formats (dw.json, normalized config, etc.).
*
* @module config/mapping
*/
import type {AuthConfig} from '../auth/types.js';
import {B2CInstance, type InstanceConfig} from '../instance/index.js';
import type {DwJsonConfig} from './dw-json.js';
import type {NormalizedConfig, ConfigWarning} from './types.js';
/**
* Converts a kebab-case string to camelCase.
*
* @param str - The kebab-case string to convert
* @returns The camelCase equivalent
*
* @example
* ```typescript
* kebabToCamelCase('code-version'); // 'codeVersion'
* kebabToCamelCase('client-id'); // 'clientId'
* kebabToCamelCase('hostname'); // 'hostname' (no change)
* ```
*/
export function kebabToCamelCase(str: string): string {
return str.replace(/-([a-z])/g, (_, char: string) => char.toUpperCase());
}
/**
* Legacy/non-standard aliases that cannot be derived by kebab→camel conversion.
* Maps alias → canonical camelCase field name.
*/
export const CONFIG_KEY_ALIASES: Record<string, string> = {
server: 'hostname',
'scapi-shortcode': 'shortCode',
'webdav-server': 'webdavHostname',
'secure-server': 'webdavHostname',
secureHostname: 'webdavHostname',
passphrase: 'certificatePassphrase',
cartridgesPath: 'cartridges',
cloudOrigin: 'mrtOrigin',
selfsigned: 'selfSigned',
'oauth-scopes': 'oauthScopes',
'auth-methods': 'authMethods',
'cip-host': 'cipHost',
};
/**
* Normalizes config keys to canonical camelCase form.
*
* Resolution order for each key:
* 1. Check CONFIG_KEY_ALIASES for legacy/non-standard names
* 2. Fall back to kebab→camelCase conversion
* 3. First value wins when multiple keys resolve to the same canonical name
*
* @param raw - The raw config object with potentially mixed key formats
* @returns A new object with all keys in canonical camelCase
*
* @example
* ```typescript
* normalizeConfigKeys({ 'client-id': 'abc', 'code-version': 'v1' });
* // { clientId: 'abc', codeVersion: 'v1' }
*
* normalizeConfigKeys({ server: 'example.com', hostname: 'other.com' });
* // { hostname: 'example.com' } (first value wins)
* ```
*/
export function normalizeConfigKeys(raw: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(raw)) {
if (value === undefined) continue;
const canonical = CONFIG_KEY_ALIASES[key] ?? kebabToCamelCase(key);
if (!(canonical in result)) {
result[canonical] = value;
}
}
return result;
}
/**
* Maps dw.json fields to normalized config format.
*
* This is the SINGLE place where dw.json field mapping happens.
* Keys are already normalized to camelCase by normalizeConfigKeys() in loadDwJson(),
* so this function only handles genuine renames (e.g., `name` → `instanceName`,
* `oauthScopes` → `scopes`).
*
* @param json - The normalized dw.json config (camelCase keys)
* @returns Normalized configuration
*
* @example
* ```typescript
* import { mapDwJsonToNormalizedConfig } from '@salesforce/b2c-tooling-sdk/config';
*
* const dwJson = { hostname: 'example.com', codeVersion: 'v1' };
* const config = mapDwJsonToNormalizedConfig(dwJson);
* // { hostname: 'example.com', codeVersion: 'v1' }
* ```
*/
/**
* Parses a cartridges value that may be a colon-separated string,
* comma-separated string, or already an array.
*/
function parseCartridges(value: string | string[] | undefined): string[] | undefined {
if (value === undefined) return undefined;
if (Array.isArray(value)) return value.length > 0 ? value : undefined;
const items = value
.split(/[,:]/)
.map((s) => s.trim())
.filter(Boolean);
return items.length > 0 ? items : undefined;
}
export function mapDwJsonToNormalizedConfig(json: DwJsonConfig): NormalizedConfig {
return {
hostname: json.hostname,
webdavHostname: json.webdavHostname,
codeVersion: json.codeVersion,
username: json.username,
password: json.password,
clientId: json.clientId,
clientSecret: json.clientSecret,
scopes: json.oauthScopes,
slasClientId: json.slasClientId,
slasClientSecret: json.slasClientSecret,
siteId: json.siteId,
shortCode: json.shortCode,
tenantId: json.tenantId,
sandboxApiHost: json.sandboxApiHost,
realm: json.realm,
cartridges: parseCartridges(json.cartridges),
contentLibrary: json.contentLibrary,
cipHost: json.cipHost,
instanceName: json.name,
authMethods: json.authMethods,
accountManagerHost: json.accountManagerHost,
mrtProject: json.mrtProject,
mrtEnvironment: json.mrtEnvironment,
mrtApiKey: json.mrtApiKey,
mrtOrigin: json.mrtOrigin,
// TLS/mTLS options
certificate: json.certificate,
certificatePassphrase: json.certificatePassphrase,
selfSigned: json.selfSigned,
};
}
/**
* Maps normalized config to dw.json format.
*
* This is the reverse of mapDwJsonToNormalizedConfig. It converts normalized
* config (camelCase) back to dw.json format (kebab-case).
*
* @param config - The normalized configuration
* @param name - Optional instance name to include
* @returns DwJsonConfig structure
*
* @example
* ```typescript
* const config = { hostname: 'example.com', codeVersion: 'v1', clientId: 'abc' };
* const dwJson = mapNormalizedConfigToDwJson(config, 'staging');
* // { name: 'staging', hostname: 'example.com', 'code-version': 'v1', 'client-id': 'abc' }
* ```
*/
export function mapNormalizedConfigToDwJson(config: Partial<NormalizedConfig>, name?: string): DwJsonConfig {
const result: DwJsonConfig = {};
if (name !== undefined) {
result.name = name;
}
if (config.hostname !== undefined) {
result.hostname = config.hostname;
}
if (config.webdavHostname !== undefined) {
result.webdavHostname = config.webdavHostname;
}
if (config.codeVersion !== undefined) {
result.codeVersion = config.codeVersion;
}
if (config.username !== undefined) {
result.username = config.username;
}
if (config.password !== undefined) {
result.password = config.password;
}
if (config.clientId !== undefined) {
result.clientId = config.clientId;
}
if (config.clientSecret !== undefined) {
result.clientSecret = config.clientSecret;
}
if (config.scopes !== undefined) {
result.oauthScopes = config.scopes;
}
if (config.slasClientId !== undefined) {
result.slasClientId = config.slasClientId;
}
if (config.slasClientSecret !== undefined) {
result.slasClientSecret = config.slasClientSecret;
}
if (config.siteId !== undefined) {
result.siteId = config.siteId;
}
if (config.shortCode !== undefined) {
result.shortCode = config.shortCode;
}
if (config.tenantId !== undefined) {
result.tenantId = config.tenantId;
}
if (config.authMethods !== undefined) {
result.authMethods = config.authMethods;
}
if (config.accountManagerHost !== undefined) {
result.accountManagerHost = config.accountManagerHost;
}
if (config.cartridges !== undefined) {
result.cartridges = config.cartridges;
}
if (config.cipHost !== undefined) {
result.cipHost = config.cipHost;
}
if (config.mrtProject !== undefined) {
result.mrtProject = config.mrtProject;
}
if (config.mrtEnvironment !== undefined) {
result.mrtEnvironment = config.mrtEnvironment;
}
if (config.mrtOrigin !== undefined) {
result.mrtOrigin = config.mrtOrigin;
}
if (config.certificate !== undefined) {
result.certificate = config.certificate;
}
if (config.certificatePassphrase !== undefined) {
result.certificatePassphrase = config.certificatePassphrase;
}
if (config.selfSigned !== undefined) {
result.selfSigned = config.selfSigned;
}
return result;
}
/**
* Options for merging configurations.
*/
export interface MergeConfigOptions {
/**
* Whether to apply hostname mismatch protection.
* When true, if overrides.hostname differs from base.hostname,
* the entire base config is ignored.
* @default true
*/
hostnameProtection?: boolean;
}
/**
* Result of merging configurations.
*/
export interface MergeConfigResult {
/** The merged configuration */
config: NormalizedConfig;
/** Warnings generated during merge (e.g., hostname mismatch) */
warnings: ConfigWarning[];
/** Whether a hostname mismatch was detected and base was ignored */
hostnameMismatch: boolean;
}
/**
* Merges configurations with hostname mismatch protection.
*
* Applies the precedence rule: overrides > base.
* If hostname protection is enabled and the override hostname differs from
* the base hostname, the entire base config is ignored to prevent
* credential leakage between different instances.
*
* @param overrides - Higher-priority config values (e.g., from CLI flags/env)
* @param base - Lower-priority config values (e.g., from dw.json)
* @param options - Merge options
* @returns Merged config with warnings
*
* @example
* ```typescript
* import { mergeConfigsWithProtection } from '@salesforce/b2c-tooling-sdk/config';
*
* const { config, warnings } = mergeConfigsWithProtection(
* { hostname: 'staging.example.com' },
* { hostname: 'prod.example.com', clientId: 'abc' },
* { hostnameProtection: true }
* );
* // config = { hostname: 'staging.example.com' }
* // warnings = [{ code: 'HOSTNAME_MISMATCH', ... }]
* ```
*/
export function mergeConfigsWithProtection(
overrides: Partial<NormalizedConfig>,
base: NormalizedConfig,
options: MergeConfigOptions = {},
): MergeConfigResult {
const warnings: ConfigWarning[] = [];
const hostnameProtection = options.hostnameProtection !== false;
// Check for hostname mismatch
const hostnameExplicitlyProvided = Boolean(overrides.hostname);
const hostnameMismatch = hostnameExplicitlyProvided && Boolean(base.hostname) && overrides.hostname !== base.hostname;
if (hostnameMismatch && hostnameProtection) {
warnings.push({
code: 'HOSTNAME_MISMATCH',
message: `Server override "${overrides.hostname}" differs from config file "${base.hostname}". Config file values ignored.`,
details: {
providedHostname: overrides.hostname,
configHostname: base.hostname,
},
});
// Return only overrides, ignore base entirely
return {
config: {...overrides} as NormalizedConfig,
warnings,
hostnameMismatch: true,
};
}
// Normal merge - overrides win, use ?? for proper undefined handling
return {
config: {
hostname: overrides.hostname ?? base.hostname,
webdavHostname: overrides.webdavHostname ?? base.webdavHostname,
codeVersion: overrides.codeVersion ?? base.codeVersion,
username: overrides.username ?? base.username,
password: overrides.password ?? base.password,
clientId: overrides.clientId ?? base.clientId,
clientSecret: overrides.clientSecret ?? base.clientSecret,
scopes: overrides.scopes ?? base.scopes,
slasClientId: overrides.slasClientId ?? base.slasClientId,
slasClientSecret: overrides.slasClientSecret ?? base.slasClientSecret,
siteId: overrides.siteId ?? base.siteId,
authMethods: overrides.authMethods ?? base.authMethods,
accountManagerHost: overrides.accountManagerHost ?? base.accountManagerHost,
shortCode: overrides.shortCode ?? base.shortCode,
tenantId: overrides.tenantId ?? base.tenantId,
cartridges: overrides.cartridges ?? base.cartridges,
contentLibrary: overrides.contentLibrary ?? base.contentLibrary,
cipHost: overrides.cipHost ?? base.cipHost,
sandboxApiHost: overrides.sandboxApiHost ?? base.sandboxApiHost,
realm: overrides.realm ?? base.realm,
instanceName: overrides.instanceName ?? base.instanceName,
projectDirectory: overrides.projectDirectory ?? base.projectDirectory,
workingDirectory: overrides.workingDirectory ?? base.workingDirectory,
mrtProject: overrides.mrtProject ?? base.mrtProject,
mrtEnvironment: overrides.mrtEnvironment ?? base.mrtEnvironment,
mrtApiKey: overrides.mrtApiKey ?? base.mrtApiKey,
mrtOrigin: overrides.mrtOrigin ?? base.mrtOrigin,
// TLS/mTLS options
certificate: overrides.certificate ?? base.certificate,
certificatePassphrase: overrides.certificatePassphrase ?? base.certificatePassphrase,
selfSigned: overrides.selfSigned ?? base.selfSigned,
},
warnings,
hostnameMismatch: false,
};
}
/**
* Gets the list of fields that have values in a config.
*
* Used for tracking which sources contributed which fields during
* configuration resolution.
*
* @param config - The configuration to inspect
* @returns Array of field names that have non-empty values
*
* @example
* ```typescript
* const config = { hostname: 'example.com', clientId: 'abc' };
* const fields = getPopulatedFields(config);
* // ['hostname', 'clientId']
* ```
*/
export function getPopulatedFields(config: NormalizedConfig): (keyof NormalizedConfig)[] {
const fields: (keyof NormalizedConfig)[] = [];
for (const [key, value] of Object.entries(config)) {
if (value !== undefined && value !== null && value !== '') {
fields.push(key as keyof NormalizedConfig);
}
}
return fields;
}
/**
* Builds an AuthConfig from a NormalizedConfig.
*
* This is the single source of truth for converting normalized config
* to the AuthConfig format expected by B2CInstance.
*
* @param config - The normalized configuration
* @returns AuthConfig for B2CInstance
*
* @example
* ```typescript
* const config = {
* clientId: 'my-client-id',
* clientSecret: 'my-secret',
* username: 'admin',
* password: 'pass',
* };
* const authConfig = buildAuthConfigFromNormalized(config);
* // { oauth: { clientId: '...', clientSecret: '...' }, basic: { username: '...', password: '...' } }
* ```
*/
export function buildAuthConfigFromNormalized(config: NormalizedConfig): AuthConfig {
const authConfig: AuthConfig = {
authMethods: config.authMethods,
};
if (config.username && config.password) {
authConfig.basic = {
username: config.username,
password: config.password,
};
}
if (config.clientId) {
authConfig.oauth = {
clientId: config.clientId,
clientSecret: config.clientSecret,
scopes: config.scopes,
accountManagerHost: config.accountManagerHost,
};
}
return authConfig;
}
/**
* Creates a B2CInstance from a NormalizedConfig.
*
* This utility provides a single source of truth for instance creation
* from resolved configuration. It is used by both ConfigResolver.createInstance()
* and CLI commands (e.g., InstanceCommand).
*
* @param config - The normalized configuration (must include hostname)
* @returns Configured B2CInstance
* @throws Error if hostname is not available in config
*
* @example
* ```typescript
* import { createInstanceFromConfig } from '@salesforce/b2c-tooling-sdk/config';
*
* const config = { hostname: 'example.demandware.net', clientId: 'abc' };
* const instance = createInstanceFromConfig(config);
* await instance.webdav.mkcol('Cartridges/v1');
* ```
*/
export function createInstanceFromConfig(
config: NormalizedConfig,
options?: {redirectUri?: string; openBrowser?: (url: string) => Promise<void>},
): B2CInstance {
if (!config.hostname) {
throw new Error('Hostname is required. Set in dw.json or provide via overrides.');
}
const instanceConfig: InstanceConfig = {
hostname: config.hostname,
codeVersion: config.codeVersion,
webdavHostname: config.webdavHostname,
// Include TLS options if certificate or self-signed mode is configured
tlsOptions:
config.certificate || config.selfSigned
? {
certificate: config.certificate,
passphrase: config.certificatePassphrase,
rejectUnauthorized: config.selfSigned !== true,
}
: undefined,
};
const authConfig = buildAuthConfigFromNormalized(config);
// Inject implicit auth options into OAuth config when present
if (authConfig.oauth && (options?.redirectUri || options?.openBrowser)) {
authConfig.oauth = {
...authConfig.oauth,
redirectUri: options.redirectUri,
openBrowser: options.openBrowser,
};
}
return new B2CInstance(instanceConfig, authConfig);
}