UNPKG

@axway/amplify-sdk

Version:

Axway Amplify SDK for Node.js

2,078 lines 66.7 kB
import Auth from './auth.js';
import crypto from 'crypto';
import errors from './errors.js';
import fs from 'fs-extra';
import open from 'open';
import path, { dirname } from 'path';
import Server from './server.js';
import setCookie from 'set-cookie-parser';
import snooplogg from 'snooplogg';
import { r as resolve } from './environments-CsDBaJ2_.js';
import * as request from '@axway/amplify-request';
import { createURL } from './util.js';
import { promisify } from 'util';
import { redact } from '@axway/amplify-utils';
import { fileURLToPath } from 'url';
import './authenticators/authenticator.js';
import 'ejs';
import './endpoints.js';
import 'jws';
import './stores/token-store.js';
import 'pluralize';
import './authenticators/client-secret.js';
import './authenticators/owner-password.js';
import './authenticators/pkce.js';
import './authenticators/signed-jwt.js';
import 'fs';
import 'uuid';
import './stores/file-store.js';
import './stores/memory-store.js';
import './stores/secure-store.js';
import 'keytar';
import 'get-port';
import 'http';

/* eslint-disable promise/no-nesting */


const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const { error, log, warn } = snooplogg('amplify-sdk');
const { highlight, note } = snooplogg.styles;

/**
 * An SDK for accessing Amplify API's.
 */
class AmplifySDK {
	/**
	 * Initializes the environment and SDK's API.
	 *
	 * @param {Object} opts - Authentication options.
	 * @param {Object} [opts.env=prod] - The environment name.
	 * @param {Object} [opts.requestOptions] - HTTP request options with proxy settings and such to
	 * create a `got` HTTP client.
	 * @access public
	 */
	constructor(opts = {}) {
		if (typeof opts !== 'object') {
			throw errors.INVALID_ARGUMENT('Expected options to be an object');
		}

		/**
		 * Authentication options including baseURL, clientID, env, realm, and token store settings.
		 * @type {Object}
		 */
		this.opts = { ...opts };
		delete this.opts.username;
		delete this.opts.password;

		/**
		 * Resolved environment-specific settings.
		 * @type {Object}
		 */
		this.env = resolve(opts.env);

		// set the defaults based on the environment
		for (const prop of [ 'baseUrl', 'platformUrl', 'realm' ]) {
			if (!opts[prop]) {
				opts[prop] = this.env[prop];
			}
		}

		/**
		 * The `got` HTTP client.
		 * @type {Function}
		 */
		this.got = request.init(opts.requestOptions);
		if (!opts.got) {
			opts.got = this.got;
		}

		/**
		 * The base Axway ID URL.
		 * @type {String}
		 */
		this.baseUrl = opts.baseUrl ? opts.baseUrl.replace(/\/$/, '') : null;

		/**
		 * The platform URL.
		 * @type {String}
		 */
		this.platformUrl = opts.platformUrl ? opts.platformUrl.replace(/\/$/, '') : null;

		/**
		 * The Axway ID realm.
		 * @type {String}
		 */
		this.realm = opts.realm;

		const { version } = fs.readJsonSync(path.resolve(__dirname, '../package.json'));

		/**
		 * The Axway ID realm.
		 *
		 * IMPORTANT! Platform explicitly checks this user agent, so do NOT change the name or case.
		 *
		 * @type {String}
		 */
		this.userAgent = `AMPLIFY SDK/${version} (${process.platform}; ${process.arch}; node:${process.versions.node})${process.env.AXWAY_CLI ? ` Axway CLI/${process.env.AXWAY_CLI}` : ''}`;

		this.auth = {
			/**
			 * Finds an authenticated account or `null` if not found. If the account is found and
			 * the access token is expired yet the refresh token is still valid, it will
			 * automatically get a valid access token.
			 * @param {String} accountName - The name of the account including the client id prefix.
			 * @param {Object} [defaultTeams] - A map of account hashes to their selected team guid.
			 * @returns {Promise<Object>} Resolves the account info object.
			 */
			find: async (accountName, defaultTeams) => {
				const account = await this.authClient.find(accountName);
				return account ? await this.auth.loadSession(account, defaultTeams) : null;
			},

			/**
			 * Retrieves platform session information such as the organizations, then mutates the
			 * account object and returns it.
			 * @param {Object} account - The account object.
			 * @param {Object} [defaultTeams] - A map of account hashes to their selected team guid.
			 * @returns {Promise<Object>} Resolves the original account info object.
			 */
			findSession: async (account, defaultTeams) => {
				if (defaultTeams && typeof defaultTeams !== 'object') {
					throw errors.INVALID_ARGUMENT('Expected default teams to be an object');
				}

				const result = await this.request('/api/v1/auth/findSession', account, {
					errorMsg: 'Failed to find session'
				});
				account.isPlatform = !!result;

				const populateOrgs = (org, orgs) => {
					account.org = {
						entitlements: Object
							.entries(org.entitlements || {})
							.reduce((obj, [ name, value ]) => {
								if (name[0] !== '_') {
									obj[name] = value;
								}
								return obj;
							}, {}),
						guid:          org.guid,
						id:            org.org_id,
						name:          org.name,
						region:        org.region,
						subscriptions: org.subscriptions || [],
						teams:         []
					};

					account.orgs = orgs.map(({ guid, name, org_id }) => ({
						default: org_id === org.org_id,
						guid,
						id: org_id,
						name
					}));
				};

				if (result) {
					const { org, orgs, role, roles, user } = result;
					populateOrgs(org, orgs);

					account.role = role;
					account.roles = roles;

					account.user = Object.assign({}, account.user, {
						axwayId:      user.axway_id,
						dateJoined:   user.date_activated,
						email:        user.email,
						firstname:    user.firstname,
						guid:         user.guid,
						lastname:     user.lastname,
						organization: user.organization,
						phone:        user.phone
					});
				} else if (account.org?.id) {
					const org = await this.org.find(account, account.org.guid);
					org.org_id = org.org_id || org.id;
					populateOrgs(org, [ org ]);
				}

				account.team = undefined;

				if (account.user.guid) {
					const { teams } = await this.team.list(account, account.org?.id, account.user.guid);
					account.org.teams = teams;

					const selectedTeamGuid = defaultTeams?.[account.hash];

					if (teams.length) {
						const team = teams.find(t => (selectedTeamGuid && t.guid === selectedTeamGuid) || (!selectedTeamGuid && t.default)) || teams[0];
						account.team = {
							default: team.default,
							guid:    team.guid,
							name:    team.name,
							roles:   account.user.guid && team.users?.find(u => u.guid === account.user.guid)?.roles || [],
							tags:    team.tags
						};
					}
				}

				return account;
			},

			/**
			 * Returns a list of all authenticated accounts.
			 * @param {Object} [opts] - Various options.
			 * @param {Object} [opts.defaultTeams] - A map of account hashes to their selected team guid.
			 * @param {Array.<String>} [opts.skip] - A list of accounts to skip validation for.
			 * @param {Boolean} [opts.validate] - When `true`, checks to see if each account has an
			 * active access token and session.
			 * @returns {Promise<Array>}
			 */
			list: async (opts = {}) => {
				if (!opts || typeof opts !== 'object') {
					throw errors.INVALID_ARGUMENT('Expected options to be an object');
				}

				return this.authClient.list()
					.then(accounts => accounts.reduce((promise, account) => {
						return promise.then(async list => {
							if (opts.validate && (!opts.skip || !opts.skip.includes(account.name))) {
								try {
									account = await this.auth.find(account.name, opts.defaultTeams);
								} catch (err) {
									warn(`Failed to load session for account "${account.name}": ${err.toString()}`);
								}
							}
							if (account?.auth) {
								delete account.auth.clientSecret;
								delete account.auth.password;
								delete account.auth.secret;
								delete account.auth.username;
								list.push(account);
							}
							return list;
						});
					}, Promise.resolve([])))
					.then(list => list.sort((a, b) => a.name.localeCompare(b.name)));
			},

			/**
			 * Populates the specified account info object with a dashboard session id and org
			 * information.
			 * @param {Object} account - The account object.
			 * @param {Object} [defaultTeams] - A map of account hashes to their selected team guid.
			 * @returns {Promise<Object>} Resolves the original account info object.
			 */
			loadSession: async (account, defaultTeams) => {
				try {
					// grab the org guid before findSession clobbers it
					const { guid } = account.org;

					account = await this.auth.findSession(account, defaultTeams);

					// validate the service account
					if (account.isPlatformTooling) {
						const filteredOrgs = account.orgs.filter(o => o.guid === guid);
						if (!filteredOrgs.length) {
							error(`Service account belongs to org "${guid}", but platform account belongs to:\n${account.orgs.map(o => `  "${o.guid}"`).join('\n')}`);
							throw new Error('The service account\'s organization does not match the specified platform account\'s organizations');
						}
						account.orgs = filteredOrgs;
					}
				} catch (err) {
					if (err.code === 'ERR_SESSION_INVALIDATED') {
						warn(`Detected invalidated session, purging account ${highlight(account.name)}`);
						await this.authClient.logout({
							accounts: [ account.name ],
							baseUrl: this.baseUrl
						});
						return null;
					}
					throw err;
				}

				await this.authClient.updateAccount(account);

				if (account.isPlatform) {
					log(`Current org: ${highlight(account.org.name)} ${note(`(${account.org.guid})`)}`);
					log('Available orgs:');
					for (const org of account.orgs) {
						log(`  ${highlight(org.name)} ${note(`(${org.guid})`)}`);
					}
				}

				delete account.auth.clientSecret;
				delete account.auth.password;
				delete account.auth.secret;
				delete account.auth.username;

				return account;
			},

			/**
			 * Authenticates a user, retrieves the access tokens, populates the session id and
			 * org info, and returns it.
			 * @param {Object} opts - Various authentication options to override the defaults set
			 * via the `Auth` constructor.
			 * @param {String} [opts.password] - The platform tooling password used to
			 * authenticate. Requires a `username` and `clientSecret` or `secretFile`.
			 * @param {String} [opts.secretFile] - The path to the PEM formatted private key used
			 * to sign the JWT.
			 * @param {String} [opts.username] - The platform tooling username used to
			 * authenticate. Requires a `password` and `clientSecret` or `secretFile`.
			 * @returns {Promise<Object>} Resolves the account info object.
			 */
			login: async (opts = {}) => {
				let account;

				// validate the username/password
				const { password, username } = opts;
				if (username || password) {
					const clientSecret = opts.clientSecret || this.opts.clientSecret;
					const secretFile   = opts.secretFile || this.opts.secretFile;
					if (!clientSecret && !secretFile) {
						throw new Error('Username/password can only be specified when using client secret or secret file');
					}
					if (!username || typeof username !== 'string') {
						throw new TypeError('Expected username to be an email address');
					}
					if (!password || typeof password !== 'string') {
						throw new TypeError('Expected password to be a non-empty string');
					}
					delete opts.username;
					delete opts.password;
				}

				// check if already logged in
				if (!opts?.force) {
					account = await this.authClient.find(opts);
					if (account && !account.auth.expired) {
						warn(`Account ${highlight(account.name)} is already authenticated`);
						const err = new Error('Account already authenticated');
						err.account = account;
						try {
							err.account = await this.auth.loadSession(account);
						} catch (e) {
							warn(e);
						}
						err.code = 'EAUTHENTICATED';
						throw err;
					}
				}

				// do the login
				account = await this.authClient.login(opts);

				// if we're in manual mode (e.g. --no-launch-browser), then return now
				if (opts.manual) {
					// account is actually an object containing `cancel`, `promise`, and `url`
					return account;
				}

				try {
					// upgrade the service account with the platform tooling account
					if (username && password) {
						// request() will set the sid and update the account in the token store
						await this.request('/api/v1/auth/login', account, {
							errorMsg: 'Failed to authenticate',
							isToolingAuth: true,
							json: {
								from: 'cli',
								username,
								password
							}
						});

						account.isPlatformTooling = true;
					}

					return await this.auth.loadSession(account);
				} catch (err) {
					// something happened, revoke the access tokens we just got and rethrow
					await this.authClient.logout({
						accounts: [ account.name ],
						baseUrl: this.baseUrl
					});
					throw err;
				}
			},

			/**
			 * Discards an access token and notifies AxwayID to revoke the access token.
			 * @param {Object} opts - Various authentication options to override the defaults set
			 * via the `Auth` constructor.
			 * @param {Array.<String>} opts.accounts - A list of accounts names.
			 * @param {Boolean} opts.all - When `true`, revokes all accounts.
			 * @param {String} [opts.baseUrl] - The base URL used to filter accounts.
			 * @param {Function} [opts.onOpenBrowser] - A callback when the web browser is about to
			 * be launched.
			 * @returns {Promise<Object>} Resolves a list of revoked credentials.
			 */
			logout: async ({ accounts, all, baseUrl = this.baseUrl } = {}) => {
				if (all) {
					accounts = await this.authClient.list();
				} else {
					if (!Array.isArray(accounts)) {
						throw errors.INVALID_ARGUMENT('Expected accounts to be a list of accounts');
					}
					if (!accounts.length) {
						return [];
					}
					accounts = (await this.authClient.list()).filter(account => accounts.includes(account.name));
				}

				for (const account of accounts) {
					if (account.isPlatform && !account.isPlatformTooling) {
						// note: there should only be 1 platform account in the accounts list
						const { platformUrl } = resolve(account.auth.env);
						const redirect = `${platformUrl}/signed.out?msg=signout`;
						const url = `${platformUrl}/auth/signout?redirect=${encodeURIComponent(redirect)}`;
						if (typeof opts.onOpenBrowser === 'function') {
							await opts.onOpenBrowser({ url });
						}
						try {
							await open(url);
						} catch (err) {
							const m = err.message.match(/Exited with code (\d+)/i);
							throw m ? new Error(`Failed to open web browser (code ${m[1]})`) : err;
						}
					}
				}

				return await this.authClient.logout({ accounts: accounts.map(account => account.hash), baseUrl });
			},

			/**
			 * Returns AxwayID server information.
			 * @param {Object} opts - Various authentication options to override the defaults set
			 * via the `Auth` constructor.
			 * @returns {Promise<object>}
			 */
			serverInfo: opts => this.authClient.serverInfo(opts),

			/**
			 * Switches your current organization.
			 * @param {Object} [account] - The account object. Note that this object reference will
			 * be updated with new org info.
			 * @param {Object|String|Number} [org] - The organization object, name, guid, or id.
			 * @param {Object} [opts] - Various options.
			 * @param {Function} [opts.onOpenBrowser] - A callback when the web browser is about to
			 * be launched.
			 * @returns {Promise<Object>} Resolves the updated account object.
			 */
			switchOrg: async (account, org, opts = {}) => {
				if (!account || account.auth.expired) {
					log(`${account ? 'Account is expired' : 'No account specified'}, doing login`);
					account = await this.authClient.login();
				} else {
					try {
						org = this.resolvePlatformOrg(account, org);
					} catch (err) {
						if (err.code !== 'ERR_INVALID_ACCOUNT' && err.code !== 'ERR_INVALID_PLATFORM_ACCOUNT' && err.code !== 'ERR_INVALID_ARGUMENT') {
							// probably org not found
							throw err;
						}
						org = undefined;
					}

					log(`Switching ${highlight(account.name)} to org ${highlight(org.name)} (${org.guid})`);

					const server = new Server();
					const { start, url: redirect } = await server.createCallback((req, res) => {
						log(`Telling browser to redirect to ${highlight(this.platformUrl)}`);
						res.writeHead(302, {
							Location: this.platformUrl
						});
						res.end();
					});

					try {
						const url = createURL(`${this.platformUrl}/auth/org.select`, {
							org_hint: org?.id,
							redirect
						});
						log(`Launching default web browser: ${highlight(url)}`);
						if (typeof opts.onOpenBrowser === 'function') {
							await opts.onOpenBrowser({ url });
						}
						try {
							await open(url);
						} catch (err) {
							const m = err.message.match(/Exited with code (\d+)/i);
							throw m ? new Error(`Failed to open web browser (code ${m[1]})`) : err;
						}

						log(`Waiting for browser to be redirected to: ${highlight(redirect)}`);
						await start();
						const authenticator = this.authClient.createAuthenticator(this.authClient.applyDefaults());
						await new Promise(resolve => setTimeout(resolve, 3000));
						account = await authenticator.getToken(undefined, undefined, true);
					} finally {
						await server.stop();
					}
				}

				try {
					log('Refreshing the account session...');
					if (account.sid) {
						log(`Deleting sid ${account.sid}`);
						delete account.sid;
					}
					return await this.auth.loadSession(account);
				} catch (e) {
					// squelch
					log(e);
				}

				throw new Error('Failed to switch organization');
			},

			async timeout() {
				return new Promise(resolve => setTimeout(resolve, 3000));
			}
		};

		this.client = {
			/**
			 * Creates a new service account.
			 * @param {Object} account - The account object.
			 * @param {Object|String|Number} org - The organization object, name, id, or guid.
			 * @param {Object} opts - Various options.
			 * @param {String} [opts.desc] - The service account description.
			 * @param {String} opts.name - The display name.
			 * @param {String} [opts.publicKey] - A PEM formatted public key.
			 * @param {Array<String>} [opts.roles] - A list of roles to assign to the service account.
			 * @param {String} [opts.secret] - A client secret key.
			 * @param {Array<Object>} [opts.teams] - A list of objects containing `guid` and `roles`
			 * properties.
			 * @returns {Promise<Object>}
			 */
			create: async (account, org, opts = {}) => {
				org = this.resolvePlatformOrg(account, org);

				if (!opts || typeof opts !== 'object') {
					throw errors.INVALID_ARGUMENT('Expected options to be an object');
				}

				if (!opts.name || typeof opts.name !== 'string') {
					throw errors.INVALID_ARGUMENT('Expected name to be a non-empty string');
				}

				if (opts.desc && typeof opts.desc !== 'string') {
					throw errors.INVALID_ARGUMENT('Expected description to be a string');
				}

				const data = {
					name:        opts.name,
					description: opts.desc || '',
					org_guid:    org.guid
				};

				if (opts.publicKey) {
					if (typeof opts.publicKey !== 'string') {
						throw errors.INVALID_ARGUMENT('Expected public key to be a string');
					}
					if (!opts.publicKey.startsWith('-----BEGIN PUBLIC KEY-----')) {
						throw new Error('Expected public key to be PEM formatted');
					}
					data.type = 'certificate';
					data.publicKey = opts.publicKey;
				} else if (opts.secret) {
					if (typeof opts.secret !== 'string') {
						throw errors.INVALID_ARGUMENT('Expected secret to be a string');
					}
					data.type = 'secret';
					data.secret = opts.secret;
				} else {
					throw new Error('Expected public key or secret');
				}

				if (opts.roles) {
					data.roles = await this.role.resolve(account, opts.roles, { client: true, org });
				}

				if (opts.teams) {
					data.teams = await this.client.resolveTeams(account, org, opts.teams);
				}

				return {
					org,
					client: await this.request('/api/v1/client', account, {
						errorMsg: 'Failed to create service account',
						json: data
					})
				};
			},

			/**
			 * Finds a service account by client id.
			 * @param {Object} account - The account object.
			 * @param {Object|String|Number} org - The organization object, name, id, or guid.
			 * @param {String} clientId - The service account's client id.
			 * @returns {Promise<Object>}
			 */
			find: async (account, org, clientId) => {
				assertPlatformAccount(account);

				const { clients } = await this.client.list(account, org);

				// first try to find the service account by guid, then client id, then name
				let client = clients.find(c => c.guid === clientId);
				if (!client) {
					client = clients.find(c => c.client_id === clientId);
				}
				if (!client) {
					client = clients.find(c => c.name === clientId);
				}

				// if still not found, error
				if (!client) {
					throw new Error(`Service account "${clientId}" not found`);
				}

				// get service account description
				const { description } = await this.request(`/api/v1/client/${client.client_id}`, account, {
					errorMsg: 'Failed to get service account'
				});

				client.description = description;

				const { teams } = await this.team.list(account, client.org_guid);
				client.teams = [];
				for (const team of teams) {
					const user = team.users.find(u => u.type === 'client' && u.guid === client.guid);
					if (user) {
						client.teams.push({
							...team,
							roles: user.roles
						});
					}
				}

				return {
					org: await this.org.find(account, client.org_guid),
					client
				};
			},

			/**
			 * Generates a new public/private key pair.
			 * @returns {Promise<Object>} Resolves an object with `publicKey` and `privateKey` properties.
			 */
			async generateKeyPair() {
				return await promisify(crypto.generateKeyPair)('rsa', {
					modulusLength: 2048,
					publicKeyEncoding: { type: 'spki', format: 'pem' },
					privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
				});
			},

			/**
			 * Retrieves a list of all service accounts for the given org.
			 * @param {Object} account - The account object.
			 * @param {Object|String|Number} org - The organization object, name, id, or guid.
			 * @returns {Promise<Object>} Resolves the service account that was removed.
			 */
			list: async (account, org) => {
				org = this.resolvePlatformOrg(account, org);
				const clients = await this.request(`/api/v1/client?org_id=${org.id}`, account, {
					errorMsg: 'Failed to get service accounts'
				});

				return {
					org,
					clients: clients
						.map(c => {
							c.method = this.client.resolveType(c.type);
							return c;
						})
						.sort((a, b) => a.name.localeCompare(b.name))
				};
			},

			/**
			 * Removes a service account.
			 * @param {Object} account - The account object.
			 * @param {Object|String|Number} org - The organization object, name, id, or guid.
			 * @param {Object|String} client - The service account object or client id.
			 * @returns {Promise<Object>} Resolves the service account that was removed.
			 */
			remove: async (account, org, client) => {
				client = await this.client.resolveClient(account, org, client);

				await this.request(`/api/v1/client/${client.client_id}`, account, {
					errorMsg: 'Failed to remove service account',
					method: 'delete'
				});

				return { client, org };
			},

			/**
			 * Resolves an org by name, id, org guid using the specified account.
			 * @param {Object} account - The account object.
			 * @param {Object|String|Number} org - The organization object, name, guid, or id.
			 * @param {Object|String} client - The service account object or client id.
			 * @returns {Promise<Object>}
			 */
			resolveClient: async (account, org, client) => {
				if (client && typeof client === 'object' && client.client_id) {
					return client;
				}

				if (client && typeof client === 'string') {
					return (await this.client.find(account, org, client)).client;
				}

				throw errors.INVALID_ARGUMENT('Expected client to be an object or client id');
			},

			/**
			 * Validates a list of teams for the given org.
			 * @param {Object} account - The account object.
			 * @param {Object|String|Number} org - The organization object, name, id, or guid.
			 * @param {Array<Object>} [teams] - A list of objects containing `guid` and `roles`
			 * properties.
			 * @returns {Array<String>} An aray of team guids.
			 */
			resolveTeams: async (account, org, teams) => {
				if (!Array.isArray(teams)) {
					throw errors.INVALID_ARGUMENT('Expected teams to be an array');
				}

				if (!teams.length) {
					return;
				}

				const { teams: availableTeams } = await this.team.list(account, org);
				const teamRoles = await this.role.list(account, { team: true, org });
				const guids = {};
				const resolvedTeams = [];

				for (const team of teams) {
					if (!team || typeof team !== 'object' || !team.guid || typeof team.guid !== 'string' || !team.roles || !Array.isArray(team.roles) || !team.roles.length) {
						throw errors.INVALID_ARGUMENT('Expected team to be an object containing a guid and array of roles');
					}

					// find the team by name or guid
					const lt = team.guid.toLowerCase().trim();
					const found = availableTeams.find(t => t.guid === lt || t.name.toLowerCase() === lt);
					if (!found) {
						throw new Error(`Invalid team "${team.guid}"`);
					}

					// validate roles
					for (const role of team.roles) {
						if (!teamRoles.find(r => r.id === role)) {
							throw new Error(`Invalid team role "${role}"`);
						}
					}

					// dedupe
					if (guids[found.guid]) {
						continue;
					}
					guids[found.guid] = 1;

					resolvedTeams.push({
						guid: found.guid,
						roles: team.roles
					});
				}

				return resolvedTeams;
			},

			/**
			 * Returns the service account auth type label.
			 * @param {String} type - The auth type.
			 * @returns {String}
			 */
			resolveType(type) {
				return type === 'secret' ? 'Client Secret' : type === 'certificate' ? 'Client Certificate' : 'Other';
			},

			/**
			 * Updates an existing service account's information.
			 * @param {Object} account - The account object.
			 * @param {Object|String|Number} org - The organization object, name, id, or guid.
			 * @param {Object} opts - Various options.
			 * @param {Object|String} opts.client - The service account object or client id.
			 * @param {String} [opts.desc] - The service account description.
			 * @param {String} [opts.name] - The display name.
			 * @param {String} [opts.publicKey] - A PEM formatted public key.
			 * @param {Array<String>} [opts.roles] - A list of roles to assign to the service account.
			 * @param {String} [opts.secret] - A client secret key.
			 * @param {Array<Object>} [opts.teams] - A list of objects containing `guid` and `roles`
			 * properties.
			 * @returns {Promise}
			 */
			update: async (account, org, opts = {}) => {
				org = this.resolvePlatformOrg(account, org);

				if (!opts || typeof opts !== 'object') {
					throw errors.INVALID_ARGUMENT('Expected options to be an object');
				}

				const client = await this.client.resolveClient(account, org, opts.client);
				const data = {};

				if (opts.name) {
					if (typeof opts.name !== 'string') {
						throw errors.INVALID_ARGUMENT('Expected name to be a non-empty string');
					}
					data.name = opts.name;
				}

				if (opts.desc) {
					if (typeof opts.desc !== 'string') {
						throw errors.INVALID_ARGUMENT('Expected description to be a string');
					}
					data.description = opts.desc;
				}

				if (opts.publicKey) {
					if (typeof opts.publicKey !== 'string') {
						throw errors.INVALID_ARGUMENT('Expected public key to be a string');
					}
					if (!opts.publicKey.startsWith('-----BEGIN PUBLIC KEY-----')) {
						throw new Error('Expected public key to be PEM formatted');
					}
					if (client.type !== 'certificate') {
						throw new Error(`Service account "${client.name}" uses auth method "${this.client.resolveType(client.type)}" and cannot be changed to "${this.client.resolveType('certificate')}"`);
					}
					data.publicKey = opts.publicKey;
				} else if (opts.secret) {
					if (typeof opts.secret !== 'string') {
						throw errors.INVALID_ARGUMENT('Expected secret to be a string');
					}
					if (client.type !== 'secret') {
						throw new Error(`Service account "${client.name}" uses auth method "${this.client.resolveType(client.type)}" and cannot be changed to "${this.client.resolveType('secret')}"`);
					}
					data.secret = opts.secret;
				}

				if (opts.roles !== undefined) {
					data.roles = !opts.roles ? [] : await this.role.resolve(account, opts.roles, { client: true, org });
				}

				if (opts.teams !== undefined) {
					data.teams = opts.teams && await this.client.resolveTeams(account, org, opts.teams) || [];
				}

				return {
					org,
					client: await this.request(`/api/v1/client/${client.guid}`, account, {
						errorMsg: 'Failed to update service account',
						json: data,
						method: 'put'
					})
				};
			}
		};

		this.entitlement = {
			/**
			 * Retrieves entitlement information for a specific entitlement metric.
			 * @param {Object} account - The account object.
			 * @param {String} metric - The entitlement metric name.
			 * @returns {Promise<Object>}
			 */
			find: (account, metric) => this.request(`/api/v1/entitlement/${metric}`, account, {
				errorMsg: 'Failed to get entitlement info'
			})
		};

		/**
		 * Retrieves activity for an organization or user.
		 * @param {Object} account - The account object.
		 * @param {Object} [params] - Various parameters.
		 * @param {String} [params.from] - The start date in ISO format.
		 * @param {String|Boolean} [params.month] - A month date range. Overrides `to` and `from`.
		 * If `true`, uses current month.
		 * @param {Object|String|Number} [params.org] - The organization object, name, guid, or id.
		 * @param {String} [params.to] - The end date in ISO format.
		 * @param {String} [params.userGuid] - The user guid.
		 * @returns {Promise<Object>}
		 */
		const getActivity = async (account, params = {}) => {
			assertPlatformAccount(account);

			if (params.month !== undefined) {
				Object.assign(params, resovleMonthRange(params.month));
			}

			let { from, to } = resolveDateRange(params.from, params.to);
			let url = '/api/v1/activity?data=true';

			if (params.org) {
				const { id } = this.resolvePlatformOrg(account, params.org);
				url += `&org_id=${id}`;
			}

			if (params.userGuid) {
				url += `&user_guid=${params.userGuid}`;
			}

			if (from) {
				url += `&from=${from.toISOString()}`;
			}

			if (to) {
				url += `&to=${to.toISOString()}`;
			}

			return {
				from,
				to,
				events: await this.request(url, account, {
					errorMsg: 'Failed to get user activity'
				})
			};
		};

		this.org = {
			/**
			 * Retieves organization activity.
			 * @param {Object} account - The account object.
			 * @param {Object|String|Number} org - The organization object, name, guid, or id.
			 * @param {Object} [params] - Various parameters.
			 * @param {String} [params.from] - The start date in ISO format.
			 * @param {String|Boolean} [params.month] - A month date range. Overrides `to` and
			 * `from`. If `true`, uses current month.
			 * @param {String} [params.to] - The end date in ISO format.
			 * @returns {Promise<Object>}
			 */
			activity: (account, org, params) => getActivity(account, {
				...params,
				org
			}),

			/**
			 * Retrieves the list of environments associated to the user's org.
			 * @param {Object} account - The account object.
			 * @returns {Promise<Array>}
			 */
			environments: async account => {
				assertPlatformAccount(account);
				return await this.request('/api/v1/org/env', account, {
					errorMsg: 'Failed to get organization environments'
				});
			},

			/**
			 * Retrieves the organization family used to determine the child orgs.
			 * @param {Object} account - The account object.
			 * @param {Object|String|Number} org - The organization object, name, id, or guid.
			 * @returns {Promise<Object>}
			 */
			family: async (account, org) => {
				const { id } = this.resolvePlatformOrg(account, org);
				return await this.request(`/api/v1/org/${id}/family`, account, {
					errorMsg: 'Failed to get organization family'
				});
			},

			/**
			 * Retrieves organization details for an account.
			 * @param {Object} account - The account object.
			 * @param {String} org - The organization object, name, id, or guid.
			 * @returns {Promise<Array>}
			 */
			find: async (account, org) => {
				const { id } = this.resolveOrg(account, org);
				org = await this.request(`/api/v1/org/${id}`, account, {
					errorMsg: 'Failed to get organization'
				});

				const subscriptions = org.subscriptions.map(s => ({
					category:   s.product,  // TODO: Replace with annotated name
					edition:    s.plan,     // TODO: Replace with annotated name
					expired:    !!s.expired,
					governance: s.governance || 'SaaS',
					startDate:  s.start_date,
					endDate:    s.end_date,
					tier:       s.tier
				}));

				const { teams } = await this.team.list(account, id);

				const result = {
					active:           org.active,
					created:          org.created,
					childOrgs:        null, // deprecated
					guid:             org.guid,
					id:               id,
					name:             org.name,
					entitlements:     org.entitlements,
					parentOrg:        null, // deprecated
					region:           org.region,
					insightUserCount: ~~org.entitlements.limit_read_only_users,
					seats:            org.entitlements.limit_users === 10000 ? null : org.entitlements.limit_users,
					subscriptions,
					teams,
					teamCount:        teams.length,
					userCount:        org.users.length,
					userRoles:        org.users.find(u => u.guid === account.user.guid)?.roles || []
				};

				if (org.entitlements?.partners) {
					for (const partner of org.entitlements.partners) {
						result[partner] = org[partner];
					}
				}

				return result;
			},

			/**
			 * Retrieves the list of orgs from the specified account.
			 * @param {Object} account - The account object.
			 * @param {String} defaultOrg - The name, id, or guid of the default organization.
			 * @returns {Promise<Array>}
			 */
			list: async (account, defaultOrg) => {
				assertPlatformAccount(account);

				const { guid } = this.resolvePlatformOrg(account, defaultOrg);

				return account.orgs.map(o => ({
					...o,
					default: o.guid === guid
				})).sort((a, b) => a.name.localeCompare(b.name));
			},

			user: {
				/**
				 * Adds a user to an org.
				 * @param {Object} account - The account object.
				 * @param {Object|String|Number} org - The organization object, name, id, or guid.
				 * @param {String} email - The user's email.
				 * @param {Array.<String>} roles - One or more roles to assign. Must include a "default" role.
				 * @returns {Promise<Object>}
				 */
				add: async (account, org, email, roles) => {
					org = this.resolvePlatformOrg(account, org);
					const { guid } = await this.request(`/api/v1/org/${org.id}/user`, account, {
						errorMsg: 'Failed to add user to organization',
						json: {
							email,
							roles: await this.role.resolve(account, roles, { org, requireDefaultRole: true })
						}
					});
					log(`User "${guid}" added to org ${org.name} (${org.guid})`);
					return {
						org,
						user: await this.org.user.find(account, org, guid)
					};
				},

				/**
				 * Finds a user and returns their information.
				 * @param {Object} account - The account object.
				 * @param {Object|String|Number} org - The organization object, name, id, or guid.
				 * @param {String} user - The user email or guid.
				 * @returns {Promise<Object>}
				 */
				find: async (account, org, user) => {
					const { users } = await this.org.user.list(account, org);
					user = user.toLowerCase();
					return users.find(m => String(m.email).toLowerCase() === user || String(m.guid).toLowerCase() === user);
				},

				/**
				 * Lists all users in an org.
				 * @param {Object} account - The account object.
				 * @param {Object|String|Number} org - The organization object, name, id, or guid.
				 * @returns {Promise<Object>}
				 */
				list: async (account, org) => {
					org = this.resolvePlatformOrg(account, org);
					const users = await this.request(`/api/v1/org/${org.id}/user?clients=1`, account, {
						errorMsg: 'Failed to get organization users'
					});
					return {
						org,
						users: users.sort((a, b) => {
							if ((a.client_id && !b.client_id) || (!a.client_id && b.client_id)) {
								return !a.client_id ? -1 : a.client_id ? 1 : 0;
							}
							const aname = a.name || `${a.firstname} ${a.lastname}`.trim();
							const bname = b.name || `${b.firstname} ${b.lastname}`.trim();
							return aname.localeCompare(bname);
						})
					};
				},

				/**
				 * Removes an user from an org.
				 * @param {Object} account - The account object.
				 * @param {Object|String|Number} org - The organization object, name, id, or guid.
				 * @param {String} user - The user email or guid.
				 * @returns {Promise<Object>}
				 */
				remove: async (account, org, user) => {
					org = this.resolvePlatformOrg(account, org);
					const found = await this.org.user.find(account, org.guid, user);

					if (!found) {
						throw new Error(`Unable to find the user "${user}"`);
					}

					return {
						org,
						user: found,
						...(await this.request(`/api/v1/org/${org.id}/user/${found.guid}`, account, {
							errorMsg: 'Failed to remove user from organization',
							method: 'delete'
						}))
					};
				},

				/**
				 * Updates a users role in an org.
				 * @param {Object} account - The account object.
				 * @param {Object|String|Number} org - The organization object, name, id, or guid.
				 * @param {String} user - The user email or guid.
				 * @param {Array.<String>} roles - One or more roles to assign. Must include a "default" role.
				 * @returns {Promise<Object>}
				 */
				update: async (account, org, user, roles) => {
					org = this.resolvePlatformOrg(account, org);
					const found = await this.org.user.find(account, org.guid, user);

					if (!found) {
						throw new Error(`Unable to find the user "${user}"`);
					}

					roles = await this.role.resolve(account, roles, { org, requireDefaultRole: true });

					return {
						org: await this.request(`/api/v1/org/${org.id}/user/${found.guid}`, account, {
							errorMsg: 'Failed to update user\'s organization roles',
							json: {
								roles
							},
							method: 'put'
						}),
						roles,
						user: await this.org.user.find(account, org, found.guid)
					};
				}
			},

			/**
			 * Renames an org.
			 * @param {Object} account - The account object.
			 * @param {Object|String|Number} org - The organization object, name, id, or guid.
			 * @param {String} name - The new organization name.
			 * @returns {Promise<Object>}
			 */
			rename: async (account, org, name) => {
				const { id, name: oldName } = this.resolvePlatformOrg(account, org);

				if (typeof name !== 'string' || !(name = name.trim())) {
					throw errors.INVALID_ARGUMENT('Organization name must be a non-empty string');
				}

				return {
					...(await this.request(`/api/v1/org/${id}`, account, {
						errorMsg: 'Failed to rename organization',
						json: { name },
						method: 'put'
					})),
					oldName
				};
			},

			/**
			 * Renames an org.
			 * @param {Object} account - The account object.
			 * @param {Object|String|Number} org - The organization object, name, id, or guid.
			 * @param {Object} [params] - Various parameters.
			 * @param {String} [params.from] - The start date in ISO format.
			 * @param {String|Boolean} [params.month] - A month date range. Overrides `to` and
			 * `from`. If `true`, uses current month.
			 * @param {String} [params.to] - The end date in ISO format.
			 * @returns {Promise<Object>}
			 */
			usage: async (account, org, params = {}) => {
				const { id } = this.resolvePlatformOrg(account, org);

				if (params.month !== undefined) {
					Object.assign(params, resovleMonthRange(params.month));
				}

				const { from, to } = resolveDateRange(params.from, params.to);

				let url = `/api/v1/org/${id}/usage`;
				if (from) {
					url += `?from=${from.toISOString()}`;
				}
				if (to) {
					url += `${from ? '&' : '?'}to=${to.toISOString()}`;
				}

				const results = await this.request(url, account, {
					errorMsg: 'Failed to get organization usage'
				});

				if (results.bundle?.metrics) {
					for (const [ metric, info ] of Object.entries(results.bundle.metrics)) {
						if (!info.name) {
							info.name = (await this.entitlement.find(account, metric)).title;
						}
					}
				}

				return {
					...results,
					from,
					to
				};
			}
		};

		this.role = {
			/**
			 * Get all roles.
			 * @param {Object} account - The account object.
			 * @param {Object} [params] - Various parameters.
			 * @param {Boolean} [params.client] - When `true`, returns client specific roles.
			 * @param {Boolean} [params.default] - When `true`, returns default roles only.
			 * @param {Object|String|Number} [params.org] - The organization object, name, id, or guid.
			 * @param {Boolean} [params.team] - When `true`, returns team specific roles.
			 * @returns {Promise<Object>}
			 */
			list: async (account, params = {}) => {
				let roles = await this.request(
					`/api/v1/role${params.team ? '?team=true' : ''}`,
					account,
					{ errorMsg: 'Failed to get roles' }
				);
				if (params.team) {
					return roles.filter(r => r.team);
				}
				if (params.client) {
					return roles.filter(r => r.client);
				}
				let org = params.org || account.org?.guid;
				if (org) {
					org = await this.org.find(account, org);
					const { entitlements, subscriptions } = org;

					roles = roles.filter(role => {
						return role.org
							&& (!role.partner || (entitlements.partners || []).includes(role.partner) && org[role.partner]?.provisioned)
							&& (!role.entitlement || entitlements[role.entitlement])
							&& (!role.subscription || subscriptions.find(sub => {
								return new Date(sub.end_date) >= new Date() && role.subscription.includes(sub.product);
							}));
					});
				}

				if (params.default) {
					return roles.filter(r => r.default);
				}
				return roles;
			},

			/**
			 * Fetches roles for the given params, then validates the supplied list of roles.
			 * @param {Object} account - The account object.
			 * @param {Array.<String>} roles - One or more roles to assign.
			 * @param {Object} [opts] - Various options.
			 * @param {Boolean} [opts.client] - When `true`, returns client specific roles.
			 * @param {Boolean} [opts.default] - When `true`, returns default roles only.
			 * @param {Object|String|Number} [opts.org] - The organization object, name, id, or guid.
			 * @param {Boolean} [opts.requireRoles] - When `true`, throws an error if roles is empty.
			 * @param {Boolean} [opts.requireDefaultRole] - When `true`, throws an error if roles is empty or if there are no default roles.
			 * @param {Boolean} [opts.team] - When `true`, validates team specific roles.
			 * @returns {Promise<Object>}
			 */
			resolve: async (account, roles, opts) => {
				if (!Array.isArray(roles)) {
					throw errors.INVALID_ARGUMENT('Expected roles to be an array');
				}

				if (!roles.length && !opts.requireRoles && !opts.requireDefaultRole) {
					return [];
				}

				const allowedRoles = await this.role.list(account, {
					client:  opts.client,
					default: opts.default,
					org:     opts.org,
					team:    opts.team
				});
				const defaultRoles = allowedRoles.filter(r => r.default).map(r => r.id);

				if (!roles.length && opts.requireDefaultRole) {
					throw new Error(`Expected at least one of the following roles: ${defaultRoles.join(', ')}`);
				}
				if (!roles.length && opts.requireRoles) {
					throw new Error(`Expected at least one of the following roles: ${allowedRoles.join(', ')}`);
				}

				roles = roles
					.reduce((arr, role) => arr.concat(role.split(',')), [])
					.map(role => {
						const lr = role.toLowerCase().trim();
						const found = allowedRoles.find(ar => ar.id === lr || ar.name.toLowerCase() === lr);
						if (!found) {
							throw new Error(`Invalid role "${role}", expected one of the following: ${allowedRoles.map(r => r.id).join(', ')}`);
						}
						return found.id;
					});

				log(`Resolved roles: ${highlight(roles.join(', '))}`);

				if (opts.requireDefaultRole && !roles.some(r => defaultRoles.includes(r))) {
					throw new Error(`You must specify a default role: ${defaultRoles.join(', ')}`);
				}

				return roles;
			}
		};

		/**
		 * Determines team info changes and prepares the team info to be sent.
		 * @param {Object} [info] - The new team info.
		 * @param {Object} [prev] - The previous team info.
		 * @returns {Promise<Object>}
		 */
		const prepareTeamInfo = (info = {}, prev) => {
			if (!info || typeof info !== 'object') {
				throw errors.INVALID_ARGUMENT('Expected team info to be an object');
			}

			const changes = {};
			const data = {};

			// populate data
			if (info.default !== undefined) {
				data.default = !!info.default;
			}
			if (info.desc !== undefined) {
				data.desc = String(info.desc).trim();
			}
			if (info.name !== undefined) {
				data.name = String(info.name).trim();
			}
			if (info.tags !== undefined) {
				if (!Array.isArray(info.tags)) {
					throw errors.INVALID_ARGUMENT('Expected team tags to be an array of strings');
				}
				data.tags = info.tags
					.reduce((arr, tag) => arr.concat(tag.split(',')), [])
					.map(tag => tag.trim());
			}

			// remove unchanged
			if (prev) {
				for (const key of Object.keys(data)) {
					if (Array.isArray(data[key])) {
						if (!(data[key] < prev[key] || data[key] > prev[key])) {
							delete data[key];
						}
					} else if (data[key] === prev[key]) {
						delete data[key];
					} else {
						changes[key] = {
							v: data[key],
							p: prev[key]
						};
					}
				}
			}

			return { changes, data };
		};

		this.team = {
			/**
			 * Creates a team in an org.
			 * @param {Object} account - The account object.
			 * @param {Object|String|Number} org - The organization object, name, id, or guid.
			 * @param {String} name - The name of the team.
			 * @param {Object} [info] - The team info.
			 * @param {String} [info.desc] - The team description.
			 * @param {Boolean} [info.default] - When `true`, makes this team the default.
			 * @param {Array.<String>} [info.tags] - A list of tags.
			 * @returns {Promise<Object>}
			 */
			create: async (account, org, name, info) => {
				org = this.resolvePlatformOrg(account, org);

				if (!name || typeof name !== 'string') {
					throw errors.INVALID_ARGUMENT('Expected name to be a non-empty string');
				}

				const { data } = prepareTeamInfo(info);
				data.name = name;
				data.org_guid = org.guid;

				return {
					org,
					team: await this.request('/api/v1/team', account, {
						errorMsg: 'Failed to create team',
						json: data
					})
				};
			},

			/**
			 * Find a team by name or guid.
			 * @param {Object} account - The account object.
			 * @param {Object|String|Number} org - The organization object, name, id, or guid.
			 * @param {String} team - The team name or guid.
			 * @returns {Promise<Object>}
			 */
			find: async (account, org, team) => {
				org = this.resolvePlatformOrg(account, org);

				if (!team || typeof team !== 'string') {
					throw errors.INVALID_ARGUMENT('Expected team to be a name or guid');
				}

				const origTeam = team;
				const { teams } = await this.team.list(account, org);
				team = team.toLowerCase();
				team = teams.find(t => t.name.toLowerCase() === team || t.guid === team);

				if (!team) {
					throw new Error(`Unable to find team "${origTeam}" in the "${org.name}" organization`);
				}

				return { org, team };
			},

			/**
			 * List all teams in an org.
			 * @param {Object} account - The account object.
			 * @param {Object|String|Number} org - The organization object, name, id, or guid.
			 * @param {String} [user] - A user guid to filter teams
			 * @returns {Promise<Object>}
			 */
			list: async (account, org, user) => {
				org = org && this.resolveOrg(account, org);
				let teams = await this.request(`/api/v1/team${org?.id ? `?org_id=${org.id}` : ''}`, account, {
					errorMsg: 'Failed to get organization teams'
				});

				if (user) {
					teams = teams.filter(team => {
						return team.users?.find(u => u.guid === user);
					});
				}

				return {
					org,
					teams: teams.sort((a, b) => a.name.localeCompare(b.name))
				};
			},

			user: {
				/**
				 * Adds a user to a team.
				 * @param {Object} account - The account object.
				 * @param {Object|String|Number} org - The organization object, name, id, or guid.
				 * @param {String} team - The team or guid.
				 * @param {String} user - The user email or guid.
				 * @param {Array.<String>} roles - One or more roles to assign. Must include a "default" role.
				 * @returns {Promise<Object>}
				 */
				add: async (account, org, team, user, roles) => {
					({ org, team } = await this.team.find(account, org, team));

					const found = await this.org.user.find(account, org.guid, user);
					if (!found) {
						throw new Error(`Unable to find the user "${user}"`);
					}

					return {
						org,
						team: await this.request(`/api/v1/team/${team.guid}/user/${found.guid}`, account, {
							errorMsg: 'Failed to add user to organization',
							json: {
								roles: await this.role.resolve(account, roles, { org, requireRoles: true, team: true }),
								type: found.client_id ? 'client' : 'user'
							}
						}),
						user: found
					};
				},

				/**
				 * Finds a user in a team.
				 * @param {Object} account - The account object.
				 * @param {Object|String|Number} org - The organization object, name, id, or guid.
				 * @param {String} team - The team or guid.
				 * @param {String} user - The user email or guid.
				 * @returns {Promise<Object>}
				 */
				find: async (account, org, team, user) => {
					let users;
					({ team, users } = await this.team.user.list(account, org, team));
					user = user.toLowerCase();
					return {
						org,
						team,
						user: users.find(m => String(m.email).toLowerCase() === user || String(m.guid).toLowerCase() === user)
					};
				},

				/**
				 * List all users of a team.
				 * @param {Object} account - The account object.
				 * @param {Object|String|Number} org - The organization object, name, id, or guid.
				 * @param {String} team - The team or guid.
				 * @returns {Promise<Object>}
				 */
				list: async (account, org, team) => {
					({ team } = await this.team.find(account, org, team));
					const { users: orgUsers } = await this.org.user.list(account, org.guid);
					const users = [];

					for (const user of team.users) {
						if (user.type === 'client') {
							const { client } = await this.client.find(account, org, user.guid);
							users.push({
								...client,
								roles: user.roles,
								teams: client.teams.length,
								type: user.type
							});
						} else {
							const orgUser = orgUsers.find(v => v.guid === user.guid);
							if (orgUser) {
								users.push({
									...orgUser,
									name: `${orgUser.firstname} ${orgUser.lastname}`.trim(),
									roles: user.roles,
									type: user.type || 'user'
								});
							} else {
								warn(`Unknown team user "${user.guid}"`);
							}
						}
					}

					return {
						org,
						team,
						users: users.sort((a, b) => {
							if (a.type !== b.type) {
								return a.type === 'user' ? -1 : a.type === 'client' ? 1 : 0;
							}
							return a.name.localeCompare(b.name);
						})
					};
				},

				/**
				 * Removes a user from a team.
				 * @param {Object} account - The account object.
				 * @param {Object|String|Number} org - The organization object, name, id, or guid.
				 * @param {String} team - The team or guid.
				 * @param {String} user - The user email or guid.
				 * @returns {Promise<Object>}
				 */
				remove: async (account, org, team, user) => {
					let found;
					({ user: found, team } = await this.team.user.find(account, org, team, user));

					if (!found) {
						throw new Error(`Unable to find the user "${user}"`);
					}

					await this.request(`/api/v1/team/${team.guid}/user/${found.guid}`, account, {
						errorMsg: 'Failed to remove user from team',
						method: 'delete'
					});

					return {
						org,
						team,
						user: found
					};
				},

				/**
				 * Updates a user's role in a team.
				 * @param {Object} account - The account object.
				 * @param {Object|String|Number} org - The organization object, name, id, or guid.
				 * @param {String} team - The team or guid.
				 * @param {String} user - The user email or guid.
				 * @param {Array.<String>} roles - One or more roles to assign. Must include a "default" role.
				 * @returns {Promise<Object>}
				 */
				update: async (account, org, team, user, roles) => {
					let found;
					({ user: found, team } = await this.team.user.find(account, org, team, user));

					if (!found) {
						throw new Error(`Unable to find the user "${user}"`);
					}

					roles = await this.role.resolve(account, roles, { org, requireRoles: true, team: true });

					team = await this.request(`/api/v1/team/${team.guid}/user/${found.guid}`, account, {
						errorMsg: 'Failed to update user\'s organization roles',
						json: {
							roles
						},
						method: 'put'
					});

					found.roles = team.users.reduce((v, u) => {
						return u.guid === found.guid ? u.roles : v;
					}, []);

					return {
						org,
						team,
						user: found,
						roles
					};
				}
			},

			/**
			 * Removes a team from an organization.
			 * @param {Object} account - The account object.
			 * @param {Object|String|Number} org - The organization object, name, id, or guid.
			 * @param {String} team - The team or guid.
			 * @returns {Promise<Object>}
			 */
			remove: async (account, org, team) => {
				({ org, team } = await this.team.find(account, org, team));

				await this.request(`/api/v1/team/${team.guid}`, account, {
					errorMsg: 'Failed to remove team',
					method: 'delete'
				});

				return { org, team };
			},

			/**
			 * Updates team information.
			 * @param {Object} account - The account object.
			 * @param {Object|String|Number} org - The organization object, name, id, or guid.
			 * @param {String} team - The team or guid.
			 * @param {Object} [info] - The team info.
			 * @param {String} [info.desc] - The team description.
			 * @param {Boolean} [info.default] - When `true`, makes this team the default.
			 * @param {Array.<String>} [info.tags] - A list of tags.
			 * @returns {Promise<Object>}
			 */
			update: async (account, org, team, info) => {
				({ org, team } = await this.team.find(account, org, team));

				const { changes, data } = prepareTeamInfo(info, team);

				if (Object.keys(data).length) {
					team = await this.request(`/api/v1/team/${team.guid}`, account, {
						errorMsg: 'Failed to update team',
						json: data,
						method: 'put'
					});
				}

				return {
					changes,
					org,
					team
				};
			}
		};

		this.user = {
			/**
			 * Retrieves an account's user's activity.
			 * @param {Object} account - The account object.
			 * @param {Object} [params] - Various parameters.
			 * @param {String} [params.from] - The start date in ISO format.
			 * @param {String|Boolean} [params.month] - A month date range. Overrides `to` and
			 * `from`. If `true`, uses current month.
			 * @param {String} [params.to] - The end date in ISO format.
			 * @returns {Promise<Object>}
			 */
			activity: (account, params) => getActivity(account, {
				...params,
				userGuid: account.user.guid
			}),

			/**
			 * Retrieves a user's information.
			 * @param {Object} account - The account object.
			 * @param {String} user - The user email or guid.
			 * @returns {Promise<Object>}
			 */
			find: async (account, user) => {
				if (typeof user === 'object' && user?.guid) {
					return user;
				}

				try {
					if (!user.includes('@')) {
						return await this.request(`/api/v1/user/${user}`, account, {
							errorMsg: 'Failed to find user'
						});
					}

					const users = await this.request(`/api/v1/user?term=${user}`, account, {
						errorMsg: 'Failed to find user'
					});

					if (users.length > 0) {
						return users[0];
					}
				} catch (err) {
					warn(err.toString());
				}

				throw new Error(`User "${user}" not found`);
			},

			/**
			 * Updates an account's user's information.
			 * @param {Object} account - The account object.
			 * @param {Object} [info] - Various user fields.
			 * @param {String} [info.firstname] - The user's first name.
			 * @param {String} [info.lastname] - The user's last name.
			 * @param {String} [info.phone] - The user's phone number.
			 * @returns {Promise<Object>}
			 */
			update: async (account, info = {}) => {
				assertPlatformAccount(account);

				if (!info || typeof info !== 'object') {
					throw errors.INVALID_ARGUMENT('Expected user info to be an object');
				}

				const changes = {};
				const json = {};
				let { user } = account;

				// populate data
				if (info.firstname !== undefined) {
					json.firstname = String(info.firstname).trim();
				}
				if (info.lastname !== undefined) {
					json.lastname = String(info.lastname).trim();
				}
				if (info.phone !== undefined) {
					json.phone = String(info.phone).trim();
				}

				// remove unchanged
				for (const key of Object.keys(json)) {
					if (json[key] === user[key]) {
						delete json[key];
					} else {
						changes[key] = {
							v: json[key],
							p: user[key]
						};
					}
				}

				if (Object.keys(json).length) {
					await this.request(`/api/v1/user/profile/${user.guid}`, account, {
						errorMsg: 'Failed to update user information',
						json,
						method: 'put'
					});

					log('Refreshing account information...');
					({ user } = await this.auth.loadSession(account));
				}

				return {
					changes,
					user
				};
			}
		};
	}

	/**
	 * Returns an Amplify Auth SDK client or creates one if it doesn't exist.
	 * @type {Auth}
	 * @access public
	 */
	get authClient() {
		try {
			if (!this._authClient) {
				this._authClient = new Auth(this.opts);
			}
			return this._authClient;
		} catch (err) {
			if (err.code === 'ERR_SECURE_STORE_UNAVAILABLE') {
				const isWin = process.platform === 'win32';
				err.message = `Secure token store is not available.\nPlease reinstall the Axway CLI by running:\n    ${isWin ? '' : 'sudo '}npm install --global ${isWin ? '' : '--unsafe-perm '}axway`;
			}
			throw err;
		}
	}

	/**
	 * Makes an HTTP request.
	 * @param {String} path - The path to append to the URL.
	 * @param {Object} account - The account object.
	 * @param {Object} [opts] - Various options.
	 * @param {Boolean} [opts.isToolingAuth] - When `true`, bypasses the the no sid/token check.
	 * @param {Object} [opts.json] - A JSON payload to send.
	 * @param {String} [opts.method] - The HTTP method to use. If not set, then uses `post` if
	 * `opts.json` is set, otherwise `get`.
	 * @param {String} [opts.resultKey='result'] - The name of the property return from the response.
	 * @returns {Promise} Resolves the JSON-parsed result.
	 * @access private
	 */
	async request(path, account, { errorMsg, isToolingAuth, json, method, resultKey = 'result' } = {}) {
		try {
			if (!account || typeof account !== 'object') {
				throw new TypeError('Account required');
			}

			const { sid } = account;
			const token = account.auth?.tokens?.access_token;
			if (!sid && !token && !isToolingAuth) {
				throw new Error('Invalid/expired account');
			}

			const url = `${this.platformUrl || this.env.platformUrl}${path}`;
			const headers = {
				Accept: 'application/json',
				'User-Agent': this.userAgent
			};

			if (account.sid) {
				headers.Cookie = `connect.sid=${account.sid}`;
			} else if (token) {
				headers.Authorization = `Bearer ${token}`;
			}

			if (!method) {
				method = json ? 'post' : 'get';
			}

			let response;
			const opts = {
				headers,
				json: json ? JSON.parse(JSON.stringify(json)) : undefined,
				responseType: 'json',
				retry: 0
			};
			let error;

			try {
				log(`${method.toUpperCase()} ${highlight(url)} ${note(`(${account.sid ? `sid ${account.sid}` : `token ${token}`})`)}`);
				if (opts.json) {
					log(redact(opts.json, { clone: true }));
				}
				response = await this.got[method](url, opts);
			} catch (e) {
				error = e;
				warn(error);
				if (error.response?.body) {
					warn(error.response.body);
				}
			}

			if (error || (path === '/api/v1/auth/findSession' && response.body?.[resultKey] === null)) {
				if ((!error || (error.code && error.code > 400)) && account.sid) {
					// sid is probably bad, try again with the token
					warn('Platform session was invalidated, trying again to reinitialize session with token');
					headers.Authorization = `Bearer ${token}`;
					delete headers.Cookie;
					log(`${method.toUpperCase()} ${highlight(url)} ${note(`(${account.sid ? `sid ${account.sid}` : `token ${token}`})`)}`);
					try {
						response = await this.got[method](url, opts);
					} catch (err) {
						// access token is invalid
						throw errors.SESSION_INVALIDATED('Platform session has been invalidated');
					}
				} else if (error) {
					throw error;
				}
			}

			const cookies = response.headers['set-cookie'];
			const connectSid = cookies && setCookie.parse(cookies).find(c => c.name === 'connect.sid' && c.value)?.value;
			if (connectSid) {
				log(`Setting sid: ${highlight(connectSid)}`);
				account.sid = connectSid;
				await this.authClient.updateAccount(account);
			}

			return response.body?.[resultKey];
		} catch (err) {
			const msg = err.response?.body?.message || err.response?.body?.description;
			err.message = `${errorMsg ? `${errorMsg}: ` : ''}${msg || err.message}`;
			if (err.response?.statusCode) {
				err.message += ` (${err.response.statusCode})`;
			}

			const code = err.response?.body?.code;
			if (code) {
				err.code = code;
			}

			throw err;
		}
	}

	/**
	 * Resolves an org by name, id, org guid using the specified account.
	 *
	 * @param {Object} account - The account object.
	 * @param {Object|String|Number} [org] - The organization object, name, guid, or id.
	 * @returns {Promise<Object>} Resolves the org info from the account object.
	 * @access public
	 */
	resolveOrg(account, org) {
		if (org && typeof org === 'object' && org.guid) {
			return org;
		}

		if (org === undefined) {
			org = account.org.guid;
		}

		if (typeof org !== 'string' && typeof org !== 'number') {
			throw errors.INVALID_ARGUMENT('Expected organization identifier');
		}

		const found = account.orgs.find(o => {
			return o.guid?.toLowerCase() === String(org).toLowerCase()
				|| String(o.id) === String(org)
				|| o.name?.toLowerCase() === String(org).toLowerCase();
		});

		if (!found) {
			throw new Error(`Unable to find the organization "${org}"`);
		}

		log(`Resolved org "${org}"${found.name ? ` as ${found.name}` : ''} (${found.id}) ${found.guid}`);

		return found;
	}

	/**
	 * Asserts the account is a platform account, then resolves an org by name, id, org guid using
	 * the specified account.
	 *
	 * @param {Object} account - The account object.
	 * @param {Object|String|Number} [org] - The organization object, name, guid, or id.
	 * @returns {Promise<Object>} Resolves the org info from the account object.
	 * @access public
	 */
	resolvePlatformOrg(account, org) {
		assertPlatformAccount(account);
		return this.resolveOrg(account, org);
	}
}

/**
 * Checks that the specified account is a platform account.
 *
 * @param {Object} account - The account object.
 */
function assertPlatformAccount(account) {
	if (!account || typeof account !== 'object') {
		throw errors.INVALID_ACCOUNT('Account required');
	}

	if (!account.isPlatform) {
		throw errors.INVALID_PLATFORM_ACCOUNT('Account must be a platform account');
	}
}

/**
 * Takes two date strings in the format `YYYY-MM-DD` and returns them as date objects.
 *
 * @param {String} [from] - The range start date.
 * @param {String} [to] - The range end date.
 * @returns {Object}
 */
function resolveDateRange(from, to) {
	const r = {
		from: null,
		to: null
	};
	const tsRE = /^\d{4}-\d{2}-\d{2}$/;
	let ts;

	if (from) {
		if (!tsRE.test(from) || isNaN(ts = Date.parse(`${from} 00:00:00 GMT`))) {
			throw new Error('Expected "from" date to be in the format YYYY-MM-DD');
		}
		r.from = new Date(ts);
	} else {
		r.from = new Date(Date.now() - (14 * 24 * 60 * 60 * 1000)); // 14 days
	}

	if (to) {
		if (!tsRE.test(to) || isNaN(ts = Date.parse(`${to} 23:59:59 GMT`))) {
			throw new Error('Expected "to" date to be in the format YYYY-MM-DD');
		}
		r.to = new Date(ts);
	} else {
		r.to = new Date();
	}

	return r;
}

/**
 * Determines the from and to date range for the specified month or month/year.
 *
 * @param {String|Number|Boolean} month - The month, year and month, or `""`/`true` for current
 * month, to create a date range from.
 * @return {Object}
 */
function resovleMonthRange(month) {
	const now = new Date();
	let year = now.getUTCFullYear();
	let monthIdx = now.getUTCMonth();
	let monthInt = monthIdx + 1;

	if (typeof month === 'number') {
		monthIdx = month - 1;
		monthInt = month;
	} else if (month !== true && month !== '') {
		if (typeof month !== 'string') {
			throw errors.INVALID_ARGUMENT('Expected month to be in the format YYYY-MM or MM');
		}

		const m = month.match(/^(?:(\d{4})-)?(\d\d?)$/);
		if (!m || !m[2]) {
			throw errors.INVALID_ARGUMENT('Expected month to be in the format YYYY-MM or MM');
		}

		if (m[1]) {
			year = parseInt(m[1]);
		}
		monthInt = parseInt(m[2]);
		monthIdx = monthInt - 1;
	}

	const days = [ 31, year % 4 === 0 ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ];
	if (!days[monthIdx]) {
		throw new RangeError(`Invalid month "${monthInt}"`);
	}

	const monthStr = String(monthIdx + 1).padStart(2, '0');
	return {
		from: `${year}-${monthStr}-01`,
		to: `${year}-${monthStr}-${days[monthIdx]}`
	};
}

export { AmplifySDK as default, resovleMonthRange };
//# sourceMappingURL=amplify-sdk.js.map