import { EventEmitter } from 'events'; import { Transaction } from 'knex'; import * as _ from 'lodash'; import StrictEventEmitter from 'strict-event-emitter-types'; import { inspect } from 'util'; import { generateUniqueKey } from '../lib/register-device'; import { Either, isLeft, isRight, Right } from 'fp-ts/lib/Either'; import * as t from 'io-ts'; import ConfigJsonConfigBackend from './configJson'; import * as FnSchema from './functions'; import * as Schema from './schema'; import { SchemaReturn, SchemaTypeKey, schemaTypes } from './schema-type'; import * as db from '../db'; import { ConfigurationValidationError, InternalInconsistencyError, } from '../lib/errors'; export type ConfigMap = { [key in T]: SchemaReturn; }; export type ConfigChangeMap = { [key in T]?: SchemaReturn; }; // Export this type renamed, for storing config keys export type ConfigKey = SchemaTypeKey; export type ConfigType = SchemaReturn; interface ConfigEventTypes { change: ConfigChangeMap; } export const configJsonBackend: ConfigJsonConfigBackend = new ConfigJsonConfigBackend( Schema.schema, ); type ConfigEventEmitter = StrictEventEmitter; class ConfigEvents extends (EventEmitter as new () => ConfigEventEmitter) {} const events = new ConfigEvents(); // Expose methods which make this module act as an EventEmitter export const on: typeof events['on'] = events.on.bind(events); export const once: typeof events['once'] = events.once.bind(events); export const removeListener: typeof events['removeListener'] = events.removeListener.bind( events, ); export async function get( key: T, trx?: Transaction, ): Promise> { const $db = trx || db.models.bind(db); if (Schema.schema.hasOwnProperty(key)) { const schemaKey = key as Schema.SchemaKey; return getSchema(schemaKey, $db).then((value) => { if (value == null) { const defaultValue = schemaTypes[key].default; if (defaultValue instanceof t.Type) { // The only reason that this would be the case in a non-function // schema key is for the meta nullOrUndefined value. We check this // by first decoding the value undefined with the default type, and // then return undefined const maybeDecoded = (defaultValue as t.Type).decode(undefined); return ( checkValueDecode(maybeDecoded, key, undefined) && maybeDecoded.right ); } return defaultValue as SchemaReturn; } const decoded = decodeSchema(schemaKey, value); // The following function will throw if the value // is not correct, so we chain it this way to keep // the type system happy return checkValueDecode(decoded, key, value) && decoded.right; }); } else if (FnSchema.fnSchema.hasOwnProperty(key)) { const fnKey = key as FnSchema.FnSchemaKey; // Cast the promise as something that produces an unknown, and this means that // we can validate the output of the function as well, ensuring that the type matches const promiseValue = FnSchema.fnSchema[fnKey](); return promiseValue.then((value: unknown) => { const decoded = schemaTypes[key].type.decode(value); return checkValueDecode(decoded, key, value) && decoded.right; }); } else { throw new Error(`Unknown config value ${key}`); } } export async function getMany( keys: T[], trx?: Transaction, ): Promise<{ [key in T]: SchemaReturn }> { const values = await Promise.all(keys.map((k) => get(k, trx))); return (_.zipObject(keys, values) as unknown) as Promise< { [key in T]: SchemaReturn } >; } export async function set( keyValues: ConfigMap, trx?: Transaction, ): Promise { const setValuesInTransaction = async (tx: Transaction) => { const configJsonVals: Dictionary = {}; const dbVals: Dictionary = {}; _.each(keyValues, (v, k: T) => { const schemaKey = k as Schema.SchemaKey; const source = Schema.schema[schemaKey].source; switch (source) { case 'config.json': configJsonVals[schemaKey] = v; break; case 'db': dbVals[schemaKey] = v; break; default: throw new Error( `Unknown configuration source: ${source} for config key: ${k}`, ); } }); const dbKeys = _.keys(dbVals) as T[]; const oldValues = await getMany(dbKeys, tx); await Promise.all( dbKeys.map(async (key: T) => { const value = dbVals[key]; // if we have anything other than a string, it must be converted to // a string before being stored in the db const strValue = valueToString(value, key); if (oldValues[key] !== value) { await db.upsertModel('config', { key, value: strValue }, { key }, tx); } }), ); if (!_.isEmpty(configJsonVals)) { await configJsonBackend.set( configJsonVals as { [name in Schema.SchemaKey]: unknown; }, ); } }; // Firstly validate and coerce all of the types as // they are being set keyValues = validateConfigMap(keyValues); if (trx != null) { await setValuesInTransaction(trx); } else { await db.transaction((tx: Transaction) => setValuesInTransaction(tx)); } events.emit('change', keyValues as ConfigMap); } export async function remove( key: T, ): Promise { if (Schema.schema[key] == null || !Schema.schema[key].mutable) { throw new Error(`Attempt to delete non-existent or immutable key ${key}`); } if (Schema.schema[key].source === 'config.json') { return configJsonBackend.remove(key); } else if (Schema.schema[key].source === 'db') { await db.models('config').del().where({ key }); } else { throw new Error( `Unknown or unsupported config backend: ${Schema.schema[key].source}`, ); } } export async function regenerateRegistrationFields(): Promise { await set({ uuid: newUniqueKey(), deviceApiKey: newUniqueKey(), }); } export function newUniqueKey(): string { return generateUniqueKey(); } export function valueIsValid( key: T, value: unknown, ): boolean { // If the default entry in the schema is a type and not a value, // use this in the validation of the value const schemaTypesEntry = schemaTypes[key as SchemaTypeKey]; let type: t.Type; if (schemaTypesEntry.default instanceof t.Type) { type = t.union([schemaTypesEntry.type, schemaTypesEntry.default]); } else { type = schemaTypesEntry.type; } return isRight(type.decode(value)); } async function getSchema( key: T, $db: Transaction, ): Promise { let value: unknown; switch (Schema.schema[key].source) { case 'config.json': value = await configJsonBackend.get(key); break; case 'db': const [conf] = await $db('config').select('value').where({ key }); if (conf != null) { return conf.value; } break; } return value; } function decodeSchema( key: T, value: unknown, ): Either> { return schemaTypes[key].type.decode(value); } function validateConfigMap( configMap: ConfigMap, ): ConfigMap { // Just loop over every value, run the decode function, and // throw if any value fails verification return _.mapValues(configMap, (value, key) => { if ( !Schema.schema.hasOwnProperty(key) || !Schema.schema[key as Schema.SchemaKey].mutable ) { throw new Error( `Attempt to set value for non-mutable schema key: ${key}`, ); } // If the default entry in the schema is a type and not a value, // use this in the validation of the value const schemaTypesEntry = schemaTypes[key as SchemaTypeKey]; let type: t.Type; if (schemaTypesEntry.default instanceof t.Type) { type = t.union([schemaTypesEntry.type, schemaTypesEntry.default]); } else { type = schemaTypesEntry.type; } const decoded = type.decode(value); if (isLeft(decoded)) { throw new TypeError( `Cannot set value for ${key}, as value failed validation: ${inspect( value, { depth: Infinity }, )}`, ); } return decoded.right; }) as ConfigMap; } export async function generateRequiredFields() { return getMany(['uuid', 'deviceApiKey', 'apiSecret', 'unmanaged']).then( ({ uuid, deviceApiKey, apiSecret, unmanaged }) => { // These fields need to be set regardless if (uuid == null || apiSecret == null) { uuid = uuid || newUniqueKey(); apiSecret = apiSecret || newUniqueKey(); } return set({ uuid, apiSecret }).then(() => { if (unmanaged) { return; } if (!deviceApiKey) { return set({ deviceApiKey: newUniqueKey() }); } }); }, ); } function valueToString(value: unknown, name: string) { switch (typeof value) { case 'object': return JSON.stringify(value); case 'number': case 'string': case 'boolean': return value.toString(); default: throw new InternalInconsistencyError( `Could not convert configuration value to string for storage, name: ${name}, value: ${value}, type: ${typeof value}`, ); } } function checkValueDecode( decoded: Either, key: string, value: unknown, ): decoded is Right { if (isLeft(decoded)) { throw new ConfigurationValidationError(key, value); } return true; } export const initialized = (async () => { await db.initialized; await generateRequiredFields(); })();