UNPKG

@sap/cds-dk

Version:

Command line client and development toolkit for the SAP Cloud Application Programming Model

466 lines (408 loc) 15.8 kB
const cds = require('../cds'); const { escapeRegex } = require('./util/strings'); const DEBUG = cds.debug('cli'); // PARAM BASE TYPES const TokenStorage = Object.freeze({ plain: 0, keyring: 1 }); const Persistence = Object.freeze({ none: 0, setting: 1, auth: 2 }); const ObfuscationLevel = Object.freeze({ none: 0, // Keep this at zero always! full: 1, partial: 2 // Allows a few start and end characters displayed in DEBUG output; only use with string-type params }); const DISCLOSURE_LENGTH = 2; /** * Parameters hold a name and a value and can only be set to compatible values. */ class Param { /** * Constructor. * @param name the name * @param val the initial value * @param options constructor options */ constructor(name, val = undefined, options = {}) { this.persist = Persistence.none; this.obfuscate = ObfuscationLevel.none; this.abbreviate = false; this.internal = false; this.allowedValues = null; this.type = val === undefined ? this.allowedValues ? typeof this.allowedValues[0] : 'string' // default : typeof val; this.name = name; this._val = val; this.options = options; Object.entries(options).forEach(([key, val]) => this[key] = val); } /** * Get the name of the main param (as opposed to an alias). */ get mainName() { return this.name; } /** * Using Proxy to achieve aliasing by routing all but certain properties through to main param. * @param name the alias name */ newAlias(name) { return new Proxy(this, { get(param, prop, alias) { switch (prop) { case 'clone': return (val) => (val ? param.clone(val) : param).newAlias(alias.name); case 'name': return name; default: return param[prop]; } } }); } /** * Set value. * @param newVal the new value */ set val(newVal) { this.validate(newVal); this._val = newVal; } /** * Get value. */ get val() { return this._val; } /** * Validate the param value. * @param val the value in question * @private */ validate(val) { if (val === undefined) { return; // Allow `undefined` always. } if (typeof val !== this.type) { throw `Failed to set '${(this.name)}' = ${JSON.stringify(val)}: expected value of type ${this.type}`; } if (this.allowedValues && !(this.allowedValues).includes(val)) { throw `Failed to set '${(this.name)}' = ${JSON.stringify(val)}: expected one of ${JSON.stringify(this.allowedValues)}`; } } /** * Transform the value to make it suitable for output, i.e. return: * - `undefined` for internal params (making them hidden) * - `'...'` for (parts of) obfuscated or (when debug output is off) abbreviated params * - `this.val` for all others */ format() { return this.internal ? undefined : this.obfuscate === ObfuscationLevel.none && (!this.abbreviate || process.env.DEBUG) ? this.val : this.obfuscate === ObfuscationLevel.partial && process.env.DEBUG && this.type === 'string' ? this.val.slice(0, DISCLOSURE_LENGTH) + '...' + this.val.slice(-DISCLOSURE_LENGTH) : '...'; } /** * Return a new instance of this class with the same properties as the current object. * @param val used as value if given */ clone(val = this.val) { this.validate(val); return new Param(this.name, val, this.options); } /** * Prevent this being changed. */ freeze() { Object.freeze(this._val); Object.freeze(this); return this; } } // type ParamType<P> = P extends Param<infer U> ? U : never; // CONCRETE PARAMS /** * The known params and their default values, restricted to those needed by the MTXS Client as a whole. */ class BasicParams { constructor() { // GENERAL PARAMS // - non-persisted this.projectFolder = new Param('projectFolder'); this.directory = this.projectFolder.newAlias('directory'); // - persisted this.appUrl = new Param('appUrl', undefined, { persist: Persistence.setting }); // any trailing slash (/) is removed this.url = this.appUrl.newAlias('url'); this.from = this.appUrl.newAlias('from'); this.to = this.appUrl.newAlias('to'); this.at = this.appUrl.newAlias('at'); this.subdomain = new Param('subdomain', undefined, { persist: Persistence.setting }); // only needed to fetch token this.passcodeUrl = new Param('passcodeUrl', undefined, { persist: Persistence.setting, abbreviate: true }); this.passcode_url = this.passcodeUrl.newAlias('passcode_url'); this.tokenUrl = new Param('tokenUrl', undefined, { persist: Persistence.setting, abbreviate: true }) // AUTH PARAMS // - non-persisted this.passcode = new Param('passcode', undefined, { obfuscate: ObfuscationLevel.partial }); this.password = new Param('password', undefined, { obfuscate: ObfuscationLevel.full }); this.clientid = new Param('clientid', undefined); this.clientsecret = new Param('clientsecret', undefined, { obfuscate: ObfuscationLevel.partial }); this.key = new Param('key', undefined, { obfuscate: ObfuscationLevel.partial }); // private key of client certificate this.tokenStorage = new Param('tokenStorage', undefined, { allowedValues: ['plain', 'keyring'] }); this.clearOtherTokenStorage = new Param('clearOtherTokenStorage', false, { internal: true }); this.plain = new Param('plain', false, { internal: true }); // for backward compatibility - superseded by tokenStorage this.saveData = new Param('saveData', false, { internal: true }); this.renewLogin = new Param('renewLogin', false, { internal: true }); this.reqAuth = new Param('reqAuth', undefined, { internal: true, type: 'object', obfuscate: ObfuscationLevel.full }); // - persisted this.username = new Param('username', undefined, { persist: Persistence.setting }); // only saved against localhost (non-productive); as setting due to ambiguous app URL this.isEmptyPassword = new Param('isEmptyPassword', false, { internal: true, persist: Persistence.setting }); // extra param so we can ensure we never save a password this.token = new Param('token', undefined, { persist: Persistence.auth, obfuscate: ObfuscationLevel.partial }); this.tokenExpirationDate = new Param('tokenExpirationDate', Number.MAX_SAFE_INTEGER, { persist: Persistence.auth }); this.refreshToken = new Param('refreshToken', undefined, { persist: Persistence.auth, obfuscate: ObfuscationLevel.partial }); } } // TODO pass these as extra arguments to lib functions /** * All known params including those needed for specific commands. */ class AllParams extends BasicParams { constructor() { super(); // `extend` PARAMS this.defaultTag = new Param('defaultTag', undefined); this.tagRule = new Param('tagRule', undefined); // `push` PARAMS this.sync = new Param('sync', false); this.async = new Param('async', false); // `subscribe` PARAMS this.body = new Param('body', undefined, { internal: true, persist: Persistence.none }); // `login` PARAMS this.skipToken = new Param('skipToken', false, { internal: true }); this.saveRefreshToken = new Param('saveRefreshToken', false, { internal: true }); // `logout` PARAMS this.deleteSettings = new Param('deleteSettings', false); this["delete-settings"] = this.deleteSettings.newAlias('delete-settings'); this.clearInvalid = new Param('clearInvalid', false); this["clear-invalid"] = this.clearInvalid.newAlias('clear-invalid'); } } const ParamDefs = Object.freeze(new AllParams()); // PARAM COLLECTIONS function collect(values, param) { return Object.assign(values, {[param.name]: param.val}); } /** * A bundle of params with values. There are no requirements on the number of params contained. */ class ParamCollection { constructor(paramValues = {}) { this.params = {}; this.isFrozen = false; for (const [name, val] of Object.entries(paramValues)) { if (val != null) { this.set(name, val); } } } /** * Throw error if this is frozen. * @private */ checkFrozen() { if (this.isFrozen) { throw new Error('cannot modify read-only parameter collection'); } } /** * Create a new frozen `ParamCollection`. * @param paramValues the param values to set */ static frozen(paramValues = {}) { const paramCollection = new ParamCollection(paramValues); paramCollection.freeze(); return paramCollection; } /** * Get the name of a main param (as opposed to an alias). * Throws an error if the param is not part of this collection. * @param name the param in question */ static mainName(name) { const paramDef = ParamDefs[name]; if (!paramDef) { throw new Error(`Unknown parameter: '${name}'`); } return paramDef.mainName; } /** * Return whether the given param is included in this collection. * @param name the param in question */ has(name) { return name in this.params; } /** * Return the value of the given param or `undefined` if not part of this collection. * @param name the param in question */ get(name) { const param = this.getParam(name); return param && param.val; } /** * Return the `Param` object for the given param or `undefined` if not part of this collection. * @param name the param in question */ getParam(name) { return this.params[name] || ParamDefs[name]; } /** * Set a value for the given param. Will add the param to this collection unless already included. * @param name the param in question * @param val the value to set */ set(name, val) { this.checkFrozen(); const mainName = ParamCollection.mainName(name); if (this.has(mainName)) { this.getParam(name).val = val; } else { this.params[mainName] = ParamDefs[mainName].clone(val); } if (mainName !== name) { DEBUG?.(`Migrated setting '${name}' to new key '${mainName}'`); } } /** * Delete the given param from this collection. * @param name the param in question */ delete(name) { this.checkFrozen(); delete this.params[name]; } /** * Prevent this collection and all included params from being changed. */ freeze() { this.toArray().forEach(param => param.freeze()); this.isFrozen = true; return this; } /** * Add all params contained in another collection to this, replacing existing params. * @param params the other `ParamCollection` */ merge(params) { this.checkFrozen(); for (const param of params) { this.set(param.name, param.val); } } /** * Add all params contained in another collection to this, not replacing existing params. * @param params the other `ParamCollection` */ mergeLower(params) { this.checkFrozen(); for (const param of params) { if (!this.has(param.name)) { this.set(param.name, param.val); } } } /** * Iterate through all contained param objects. */ *[Symbol.iterator]() { for (const param of Object.values(this.params)) { yield param; } } /** * Return a new array from all contained param objects. */ toArray() { return Array.from(this); } /** * Get the number of contained params. */ get size() { return Object.keys(this.params).length; } /** * Return a new `ParamCollection` containing clones of all params contained in this collection. */ clone() { const clonedParams = this.toArray() .map(param => param.clone()) .reduce(collect, {}); return new ParamCollection(clonedParams); } /** * Return an object mapping the names of each of the contained params to their corresponding values. * @param persistence the type of persistence to filter for (omit to disable filtering) */ toEntries(persistence) { return this.toArray() .filter(param => param.persist === persistence || persistence === undefined) .reduce(collect, {}); } /** * Return an object mapping each of the contained params' names to its corresponding value. * Used by `JSON.stringify`. */ toJSON() { return this.toEntries(); } /** * Format this as a string. Omits `undefined` values. * @param actualValues whether to include all actual param values, irrespective of any transformations normally * applied (e.g. value obfuscations) */ format(actualValues = false) { return this.toArray() .reduce((result, param) => { const val = actualValues ? param.val : param.format(); if (val !== undefined) { result += `${param.name}: ${JSON.stringify(val)}, `; } return result; }, '{ ') .replace(/(,|(?<={)) $/, ' }'); } /** * Replace all occurrences of values of those params that are to be obfuscated with their (partly) obfuscated versions. * Replacement applies only to values of query parameters. * @param url the URL whose query parameters to replace */ obfuscateQueryParams(url) { function reqParam(value) { return new RegExp(`(?<==)${escapeRegex(value)}(?=&|$)`, 'g'); } return this.toArray() .filter(param => param.obfuscate) .reduce((result, param) => result.replace(reqParam(param.val), param.format()), url); } } ParamCollection.prototype.toString = ParamCollection.prototype.format; module.exports = { Param, ParamCollection, Persistence, TokenStorage, ObfuscationLevel }