Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
81 changes: 80 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,80 @@
# node-utils
# NodeUtils

This module contains common Node utilities for NYPL components.

## Usage

```
npm i --save @nypl/node-utils
```

### Config

To load and decrypt config:

`loadConfig(envName) {Promise<object>}`
- `envName {string}` - Indicates what file to read config from (i.e. `./config/{envName}.yaml`)

As a convenience, after initialization (via above) you may use this function to retrieve select config syncronously:

`getConfig() {object}` - Get previously retrieved config syncronously.

```
const { config } = require('@nypl/node-utils')

const init = async () => {
// Destructure the variables you want from the initial loadConfig call:
const { LOG_LEVEL } = await config.loadConfig()

// Subsequent loadConfig calls use cached config:
const { OTHER_VAR } = await config.loadConfig()
...

// As a convenience, getConfig provides sync access to all config after initial load:
const { CLIENT_ID: id, CLIENT_SECRET: secret } = config.getConfig()
const client = SomeClient({ id, secret })
}
```

Config files must be structured like this:

```
PLAINTEXT_VARIABLES:
one: ...
two: ...
ENCRYPTED_VARIABLES:
three: ...
```

#### Troubleshooting

If you encounter `KmsError: CredentialsProviderError during decrypt command`, you may need to indicate the AWS profile to use by setting, for example, `AWS_PROFILE=nypl-digital-dev`.

### Logger

To print log entries:

`logger.(error|warning|info|debug)(message, obj?)` - Print entry to log
- `message {string}` - The message to print
- `obj {object}` - An optional plainobject with properties to include in the log entry

The logger comes pre-initialized, but you may want to re-initialize it if some env vars changed:

`logger.initialize(config?)`
- `config {object}` - Optional plainobject defining `json {bool}` and/or `level {string}`

To simply change the log level:

`logger.setLevel(level)` - Convenience for setting log level directly
- `level {string}` - Set level to 'error', 'warn', 'info', 'debug'

```
const { logger, config } = require('@nypl/node-utils')

const init = () => {
await config.loadConfig()
logger.initialize()

logger.info('Something happened', { id: '...' })
}
```
117 changes: 117 additions & 0 deletions lib/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
const yaml = require('yaml')
const assert = require('node:assert')
const fs = require('fs')

const { decrypt } = require('./kms')

class LoadConfigError extends Error {}

/**
* @typedef {Object} ReadConfig
* @property {Object} plaintext - Hash of plaintext vars
* @property {Object} encrypted - Hash of encrypted vars
* **/

/**
* Given an envName, loads config/{envName}.yaml and returns ReadConfig hash
*
* @return {ReadConfig}
* **/
const _readAndParseConfig = (envName) => {
if (!envName) {
throw new LoadConfigError('Error loading config: No envName given')
}

const path = `./config/${envName}.yaml`
let raw
try {
raw = fs.readFileSync(path, 'utf8')
} catch (e) {
throw new LoadConfigError(`Error loading config at ${path}`, { cause: e })
}

let parsed
try {
parsed = yaml.parse(raw)
} catch (e) {
throw new LoadConfigError(`Error parsing config at ${path}`, { cause: e })
}

const config = {}
;['PLAINTEXT_VARIABLES', 'ENCRYPTED_VARIABLES'].forEach((key) => {
if (parsed[key]) {
assert(typeof parsed[key] === 'object', `${key} must define an object`)

const name = key.split('_')[0].toLowerCase()
config[name] = parsed[key]
}
})

return config
}

let _configPromise
let _config

/**
* Load named config, decrypting as necessary
*
* @return {Promise<object>} An object with all decrypted config
* **/
const loadConfig = (envName = process.env.ENVIRONMENT) => {
if (!envName) {
throw new LoadConfigError('loadConfig requires an environment name (or ENVIRONMENT=...)')
}

if (_configPromise) {
return _configPromise
}

const { plaintext, encrypted } = module.exports._readAndParseConfig(envName)

const config = plaintext || {}

// Load encrypted variables:
if (encrypted) {
_configPromise = decrypt(encrypted)
.then((decryptedConfig) => ({ ...config, ...decryptedConfig }))
} else {
_configPromise = Promise.resolve(config)
}

_configPromise = _configPromise.then((c) => {
_config = c
return c
})

return _configPromise
}

/**
* Retrieve config syncronously.
*
* @return {object} config
* @throw {LoadConfigError} if config not fully loaded yet.
* **/
const getConfig = () => {
if (!_config) {
throw new LoadConfigError('Attempted to read config before initialized')
}

return _config
}

/**
* For testing: need a way to reset fetch state:
* **/
const _reset = () => {
_configPromise = _config = undefined
}

module.exports = {
loadConfig,
getConfig,
LoadConfigError,
_readAndParseConfig,
_reset
}
86 changes: 86 additions & 0 deletions lib/kms.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
const { KMSClient, DecryptCommand } = require('@aws-sdk/client-kms')
const logger = require('./logger')

class KmsError extends Error {}

const kms = {}

/**
* Given an array of encrypted strings, resolves an array of decrypted strings
* in the same order
* */
kms._decryptArray = (encrypted) => {
return Promise.all(
encrypted.map((encrypted) => kms._decryptString(encrypted))
)
}

/**
* Given an object with encrypted values, returns an object with the same keys
* and decrypted values
* */
kms._decryptObject = async (encrypted) => {
const pairs = await Promise.all(
Object.entries(encrypted)
.map(([key, value]) => {
return kms._decryptString(value).then((decrypted) => [key, decrypted])
})
)

// Assemble decryped pairs into a hash:
return pairs.reduce((h, [key, value]) => ({ ...h, [key]: value }), {})
}

/**
* Given an encrypted string, returns the decrypted string.
* */
kms._decryptString = async (encrypted) => {
let client
let response
const config = {
region: process.env.AWS_REGION || 'us-east-1'
}
try {
client = new KMSClient(config)
} catch (e) {
throw new KmsError('Error instantiating KMS client', { cause: e })
}
const command = new DecryptCommand({
CiphertextBlob: Buffer.from(encrypted, 'base64')
})
try {
response = await client.send(command)
} catch (e) {
const isCredentialsError = e.name === 'CredentialsProviderError'
const message = isCredentialsError
? `${e.name} error: Try setting AWS_PROFILE=...`
: `${e.name} during decrypt command`
throw new KmsError(message, { cause: e })
}
if (!response?.Plaintext) {
throw new KmsError('Invalid KMS response')
}
const decoded = Buffer.from(response.Plaintext, 'binary')
.toString('utf8')
return decoded
}

/**
* Given a string, string[], or object containing encrypted values, returns
* decrypted form
*
* @param {(string|string[]|object)} encrypted
* **/
kms.decrypt = (encrypted) => {
if (Array.isArray(encrypted)) {
return kms._decryptArray(encrypted)
} else if (typeof encrypted === 'object') {
return kms._decryptObject(encrypted)
} else if (typeof encrypted === 'string') {
return kms._decryptString(encrypted)
} else {
throw new KmsError(`decryptAll expected string|object|array; got ${typeof arrayOrHash}`)
}
}

module.exports = kms
67 changes: 67 additions & 0 deletions lib/logger.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
const winston = require('winston')

/**
* Get default config based on env vars:
* */
const defaultConfig = () => {
return {
json: (
process.env.LOG_STYLE === 'json' ||
typeof process.env.AWS_EXECUTION_ENV === 'string' ||
typeof process.env.LAMBDA_TASK_ROOT === 'string'
),
level: process.env.LOG_LEVEL || 'info'
}
}

// Initialize config based on env vars at runtime:
let _config = defaultConfig()

// Createe logger instance:
const logger = winston.createLogger({
level: _config.level
})

/**
* (Re-)initialize based on env vars:
* */
logger.initialize = (config = {}) => {
_config = { ...defaultConfig(), ...config }

logger.setFormat(_config)
logger.setLevel(_config.level)
}

/**
* Reset format based on config
* */
logger.setFormat = (config) => {
if (logger.transports.length) {
logger.remove(logger.transports[0])
}

// In deployed code, let's do JSON logging to enable CW JSON queries
const format = config.json
? winston.format.json()
// Locally, let's do colorized plaintext logging:
: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)

logger.add(new winston.transports.Console({ format }))
}

/**
* Set log-level
*
* @param {string} level - One of error, warn, info, debug
*/
logger.setLevel = (level) => {
logger.level = level
}

// Initialize logger based on env vars at runtime:
logger.initialize()

module.exports = logger
7 changes: 7 additions & 0 deletions node-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const logger = require('./lib/logger')
const config = require('./lib/config')

module.exports = {
logger,
config
}
Loading