1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-10 01:16:39 +02:00

feat: Add technical support for projects

This commit is contained in:
Ivar Conradi Østhus 2020-09-28 21:54:44 +02:00
parent 144e832cdc
commit b644071a34
26 changed files with 608 additions and 33 deletions

View File

@ -36,6 +36,7 @@ module.exports = function(config) {
app.use(responseTime(config));
app.use(requestLogger(config));
app.use(secureHeaders(config));
app.use(express.urlencoded({ extended: true }));
if (config.publicFolder) {
app.use(favicon(path.join(config.publicFolder, 'favicon.ico')));

View File

@ -16,6 +16,7 @@ const FEATURE_COLUMNS = [
'name',
'description',
'type',
'project',
'enabled',
'stale',
'strategies',
@ -65,6 +66,14 @@ class FeatureToggleStore {
return rows.map(this.rowToFeature);
}
async getFeaturesBy(fields) {
const rows = await this.db
.select(FEATURE_COLUMNS)
.from(TABLE)
.where(fields);
return rows.map(this.rowToFeature);
}
async count() {
return this.db
.count('*')
@ -114,6 +123,7 @@ class FeatureToggleStore {
name: row.name,
description: row.description,
type: row.type,
project: row.project,
enabled: row.enabled > 0,
stale: row.stale,
strategies: row.strategies,
@ -127,6 +137,7 @@ class FeatureToggleStore {
name: data.name,
description: data.description,
type: data.type,
project: data.project,
enabled: data.enabled ? 1 : 0,
stale: data.stale,
archived: data.archived ? 1 : 0,

View File

@ -12,6 +12,7 @@ const ClientApplicationsStore = require('./client-applications-store');
const ContextFieldStore = require('./context-field-store');
const SettingStore = require('./setting-store');
const UserStore = require('./user-store');
const ProjectStore = require('./project-store');
module.exports.createStores = (config, eventBus) => {
const { getLogger } = config;
@ -49,5 +50,6 @@ module.exports.createStores = (config, eventBus) => {
),
settingStore: new SettingStore(db, getLogger),
userStore: new UserStore(db, getLogger),
projectStore: new ProjectStore(db, getLogger),
};
};

95
lib/db/project-store.js Normal file
View File

@ -0,0 +1,95 @@
const NotFoundError = require('../error/notfound-error');
const COLUMNS = ['id', 'name', 'description', 'created_at'];
const TABLE = 'projects';
class ProjectStore {
constructor(db, getLogger) {
this.db = db;
this.logger = getLogger('project-store.js');
}
fieldToRow(data) {
return {
id: data.id,
name: data.name,
description: data.description,
};
}
async getAll() {
const rows = await this.db
.select(COLUMNS)
.from(TABLE)
.orderBy('name', 'asc');
return rows.map(this.mapRow);
}
async get(id) {
return this.db
.first(COLUMNS)
.from(TABLE)
.where({ id })
.then(this.mapRow);
}
async hasProject(id) {
return this.db
.first('id')
.from(TABLE)
.where({ id })
.then(row => {
if (!row) {
throw new NotFoundError(`No project with id=${id} found`);
}
return {
id: row.id,
archived: row.archived === 1,
};
});
}
async create(project) {
await this.db(TABLE)
.insert(this.fieldToRow(project))
.catch(err =>
this.logger.error('Could not insert project, error: ', err),
);
}
async update(data) {
try {
await this.db(TABLE)
.where({ id: data.id })
.update(this.fieldToRow(data));
} catch (err) {
this.logger.error('Could not update project, error: ', err);
}
}
async delete(id) {
try {
await this.db(TABLE)
.where({ id })
.del();
} catch (err) {
this.logger.error('Could not delete project, error: ', err);
}
}
mapRow(row) {
if (!row) {
throw new NotFoundError('No project found');
}
return {
id: row.id,
name: row.name,
description: row.description,
createdAt: row.created_at,
};
}
}
module.exports = ProjectStore;

View File

@ -0,0 +1,26 @@
'use strict';
class InvalidOperationError extends Error {
constructor(message) {
super();
Error.captureStackTrace(this, this.constructor);
this.name = this.constructor.name;
this.message = message;
}
toJSON() {
const obj = {
isJoi: true,
name: this.constructor.name,
details: [
{
message: this.message,
},
],
};
return obj;
}
}
module.exports = InvalidOperationError;

View File

@ -18,6 +18,9 @@ const {
CONTEXT_FIELD_CREATED,
CONTEXT_FIELD_UPDATED,
CONTEXT_FIELD_DELETED,
PROJECT_CREATED,
PROJECT_UPDATED,
PROJECT_DELETED,
} = require('./event-type');
const strategyTypes = [
@ -43,6 +46,8 @@ const contextTypes = [
CONTEXT_FIELD_UPDATED,
];
const projectTypes = [PROJECT_CREATED, PROJECT_UPDATED, PROJECT_DELETED];
function baseTypeFor(event) {
if (featureTypes.indexOf(event.type) !== -1) {
return 'features';
@ -53,6 +58,9 @@ function baseTypeFor(event) {
if (contextTypes.indexOf(event.type) !== -1) {
return 'context';
}
if (projectTypes.indexOf(event.type) !== -1) {
return 'project';
}
throw new Error(`unknown event type: ${JSON.stringify(event)}`);
}

View File

@ -15,4 +15,7 @@ module.exports = {
CONTEXT_FIELD_CREATED: 'context-field-created',
CONTEXT_FIELD_UPDATED: 'context-field-updated',
CONTEXT_FIELD_DELETED: 'context-field-deleted',
PROJECT_CREATED: 'project-created',
PROJECT_UPDATED: 'project-updated',
PROJECT_DELETED: 'project-deleted',
};

View File

@ -12,6 +12,9 @@ const UPDATE_APPLICATION = 'UPDATE_APPLICATION';
const CREATE_CONTEXT_FIELD = 'CREATE_CONTEXT_FIELD';
const UPDATE_CONTEXT_FIELD = 'UPDATE_CONTEXT_FIELD';
const DELETE_CONTEXT_FIELD = 'DELETE_CONTEXT_FIELD';
const CREATE_PROJECT = 'CREATE_PROJECT';
const UPDATE_PROJECT = 'UPDATE_PROJECT';
const DELETE_PROJECT = 'DELETE_PROJECT';
module.exports = {
ADMIN,
@ -26,4 +29,7 @@ module.exports = {
CREATE_CONTEXT_FIELD,
UPDATE_CONTEXT_FIELD,
DELETE_CONTEXT_FIELD,
CREATE_PROJECT,
UPDATE_PROJECT,
DELETE_PROJECT,
};

View File

@ -65,6 +65,7 @@ const featureShema = joi
enabled: joi.boolean().default(false),
stale: joi.boolean().default(false),
type: joi.string().default('release'),
project: joi.string().default('default'),
description: joi
.string()
.allow('')

View File

@ -17,14 +17,12 @@ test('should require URL firendly name', t => {
test('should be valid toggle name', t => {
const toggle = {
name: 'app.name',
type: 'release',
enabled: false,
stale: false,
strategies: [{ name: 'default' }],
};
const { value } = featureShema.validate(toggle);
t.deepEqual(value, toggle);
t.is(value.name, toggle.name);
});
test('should strip extra variant fields', t => {
@ -52,6 +50,7 @@ test('should allow weightType=fix', t => {
const toggle = {
name: 'app.name',
type: 'release',
project: 'default',
enabled: false,
stale: false,
strategies: [{ name: 'default' }],
@ -95,6 +94,7 @@ test('should be possible to define variant overrides', t => {
const toggle = {
name: 'app.name',
type: 'release',
project: 'some',
enabled: false,
stale: false,
strategies: [{ name: 'default' }],
@ -152,6 +152,7 @@ test('should keep constraints', t => {
const toggle = {
name: 'app.constraints',
type: 'release',
project: 'default',
enabled: false,
stale: false,
strategies: [

View File

@ -31,6 +31,7 @@ const handleErrors = (res, logger, error) => {
switch (error.name) {
case 'NotFoundError':
return res.status(404).end();
case 'InvalidOperationError':
case 'NameExistsError':
return res
.status(409)

View File

@ -7,18 +7,20 @@ const getApp = require('./app');
const { startMonitoring } = require('./metrics');
const { createStores } = require('./db');
const { createServices } = require('./services');
const { createOptions } = require('./options');
const StateService = require('./services/state-service');
const User = require('./user');
const permissions = require('./permissions');
const AuthenticationRequired = require('./authentication-required');
const { addEventHook } = require('./event-hook');
const eventType = require('./event-type');
async function createApp(options) {
// Database dependencies (stateful)
const logger = options.getLogger('server-impl.js');
const eventBus = new EventEmitter();
const stores = createStores(options, eventBus);
const services = createServices(stores, options, eventBus);
const secret = await stores.settingStore.get('unleash.secret');
const config = {
@ -37,7 +39,8 @@ async function createApp(options) {
addEventHook(config.eventHook, stores.eventStore);
}
const stateService = new StateService(config);
// TODO: refactor this. Should only be accessable via services object
const { stateService } = services;
config.stateService = stateService;
if (config.importFile) {
await stateService.importFile({
@ -53,6 +56,7 @@ async function createApp(options) {
app,
config,
stores,
services,
eventBus,
stateService,
};
@ -101,4 +105,5 @@ module.exports = {
User,
AuthenticationRequired,
permissions,
eventType,
};

7
lib/services/index.js Normal file
View File

@ -0,0 +1,7 @@
const ProjectService = require('./project-service');
const StateService = require('./state-service');
module.exports.createServices = (stores, config) => ({
projectService: new ProjectService(stores, config),
stateService: new StateService(stores, config),
});

View File

@ -0,0 +1,17 @@
const joi = require('joi');
const { nameType } = require('../routes/admin-api/util');
const projectSchema = joi
.object()
.keys({
id: nameType,
name: joi.string().required(),
description: joi
.string()
.allow(null)
.allow('')
.optional(),
})
.options({ allowUnknown: false, stripUnknown: true });
module.exports = projectSchema;

View File

@ -0,0 +1,93 @@
const NameExistsError = require('../error/name-exists-error');
const InvalidOperationError = require('../error/invalid-operation-error');
const eventType = require('../event-type');
const { nameType } = require('../routes/admin-api/util');
const schema = require('./project-schema');
class ProjectService {
constructor(
{ projectStore, eventStore, featureToggleStore },
{ getLogger },
) {
this.projectStore = projectStore;
this.eventStore = eventStore;
this.featureToggleStore = featureToggleStore;
this.logger = getLogger('services/project-service.js');
}
async getProjects() {
return this.projectStore.getAll();
}
async getProject(id) {
return this.projectStore.get(id);
}
async createProject(newProject, username) {
const data = await schema.validateAsync(newProject);
await this.validateUniqueId(data);
await this.eventStore.store({
type: eventType.PROJECT_CREATED,
createdBy: username,
data,
});
await this.projectStore.create(data);
}
async updateProject(updatedProject, username) {
await this.projectStore.get(updatedProject.id);
const project = await schema.validateAsync(updatedProject);
await this.eventStore.store({
type: eventType.PROJECT_UPDATED,
createdBy: username,
data: project,
});
await this.projectStore.update(project);
}
async deleteProject(id, username) {
if (id === 'default') {
throw new InvalidOperationError(
'You can not delete the default project!',
);
}
const toggles = await this.featureToggleStore.getFeaturesBy({
project: id,
archived: 0,
});
if (toggles.length > 0) {
throw new InvalidOperationError(
'You can not delete as project with active feature toggles',
);
}
await this.eventStore.store({
type: eventType.PROJECT_DELETED,
createdBy: username,
data: { id },
});
await this.projectStore.delete(id);
}
async validateId(id) {
await nameType.validateAsync(id);
await this.validateUniqueId(id);
return true;
}
async validateUniqueId(id) {
try {
await this.projectStore.hasProject(id);
} catch (error) {
// No conflict, everything ok!
return;
}
// Interntional throw here!
throw new NameExistsError('A project with this id already exists.');
}
}
module.exports = ProjectService;

View File

@ -14,7 +14,7 @@ const {
} = require('./state-util');
class StateService {
constructor({ stores, getLogger }) {
constructor(stores, { getLogger }) {
this.eventStore = stores.eventStore;
this.toggleStore = stores.featureToggleStore;
this.strategyStore = stores.strategyStore;

View File

@ -15,7 +15,10 @@ const {
function getSetup() {
const stores = store.createStores();
return { stateService: new StateService({ stores, getLogger }), stores };
return {
stateService: new StateService(stores, { getLogger }),
stores,
};
}
test('should import a feature', async t => {

View File

@ -0,0 +1,34 @@
/* eslint camelcase: "off" */
'use strict';
const async = require('async');
exports.up = function(db, cb) {
async.series(
[
db.createTable.bind(db, 'projects', {
id: {
type: 'string',
length: 255,
primaryKey: true,
notNull: true,
},
name: { type: 'string', notNull: true },
description: { type: 'string' },
created_at: { type: 'timestamp', defaultValue: 'now()' },
}),
db.runSql.bind(
db,
`
INSERT INTO projects(id, name, description) VALUES('default', 'Default', 'Default project');
`,
),
],
cb,
);
};
exports.down = function(db, cb) {
return db.dropTable('feature_types', cb);
};

View File

@ -0,0 +1,17 @@
'use strict';
exports.up = function(db, cb) {
return db.addColumn(
'features',
'project',
{
type: 'string',
defaultValue: 'default',
},
cb,
);
};
exports.down = function(db, cb) {
return db.removeColumn('features', 'project', cb);
};

View File

@ -75,7 +75,7 @@
"express": "^4.17.1",
"gravatar-url": "^3.1.0",
"helmet": "^4.1.0",
"joi": "^17.2.0",
"joi": "^17.3.0",
"js-yaml": "^3.14.0",
"knex": "0.21.5",
"log4js": "^6.0.0",

View File

@ -24,6 +24,7 @@ async function resetDatabase(stores) {
stores.db('client_instances').del(),
stores.db('context_fields').del(),
stores.db('users').del(),
stores.db('projects').del(),
]);
}
@ -43,6 +44,10 @@ function createClientInstance(store) {
return dbState.clientInstances.map(i => store.insert(i));
}
function createProjects(store) {
return dbState.projects.map(i => store.create(i));
}
function createFeatures(store) {
return dbState.features.map(f => store._createFeature(f));
}
@ -54,6 +59,7 @@ async function setupDatabase(stores) {
updates.push(...createFeatures(stores.featureToggleStore));
updates.push(...createClientInstance(stores.clientInstanceStore));
updates.push(...createApplications(stores.clientApplicationsStore));
updates.push(...createProjects(stores.projectStore));
await Promise.all(updates);
}

View File

@ -149,5 +149,11 @@
{ "name": "new", "weight": 50 }
]
}
],
"projects": [
{
"id": "default",
"name": "Default"
}
]
}

View File

@ -19,7 +19,7 @@ function createApp(stores, adminAuthentication = 'none', preHook) {
adminAuthentication,
secret: 'super-secret',
sessionAge: 4000,
stateService: new StateService({ stores, getLogger }),
stateService: new StateService(stores, { getLogger }),
getLogger,
});
}

View File

@ -0,0 +1,148 @@
const test = require('ava');
const dbInit = require('../helpers/database-init');
const getLogger = require('../../fixtures/no-logger');
const ProjectService = require('../../../lib/services/project-service');
let stores;
// let projectStore;
let projectService;
test.before(async () => {
const db = await dbInit('project_service_serial', getLogger);
stores = db.stores;
// projectStore = stores.projectStore;
projectService = new ProjectService(stores, { getLogger });
});
test.after(async () => {
await stores.db.destroy();
});
test.serial('should have default project', async t => {
const project = await projectService.getProject('default');
t.assert(project);
t.is(project.id, 'default');
});
test.serial('should list all projects', async t => {
const project = {
id: 'test-list',
name: 'New project',
description: 'Blah',
};
await projectService.createProject(project, 'someUser');
const projects = await projectService.getProjects();
t.is(projects.length, 2);
});
test.serial('should create new project', async t => {
const project = {
id: 'test',
name: 'New project',
description: 'Blah',
};
await projectService.createProject(project, 'someUser');
const ret = await projectService.getProject('test');
t.deepEqual(project.id, ret.id);
t.deepEqual(project.name, ret.name);
t.deepEqual(project.description, ret.description);
t.truthy(ret.createdAt);
});
test.serial('should delete project', async t => {
const project = {
id: 'test-delete',
name: 'New project',
description: 'Blah',
};
await projectService.createProject(project, 'some-user');
await projectService.deleteProject(project.id, 'some-user');
try {
await projectService.getProject(project.id);
} catch (err) {
t.is(err.message, 'No project found');
}
});
test.serial('should not be able to delete project with toggles', async t => {
const project = {
id: 'test-delete-with-toggles',
name: 'New project',
description: 'Blah',
};
await projectService.createProject(project, 'some-user');
await stores.featureToggleStore._createFeature({
name: 'test-project-delete',
project: project.id,
enabled: false,
});
try {
await projectService.deleteProject(project.id, 'some-user');
} catch (err) {
t.is(
err.message,
'You can not delete as project with active feature toggles',
);
}
});
test.serial('should not delete "default" project', async t => {
try {
await projectService.deleteProject('default', 'some-user');
} catch (err) {
t.is(err.message, 'You can not delete the default project!');
}
});
test.serial('should validate name, legal', async t => {
const result = await projectService.validateId('new_name');
t.true(result);
});
test.serial('should require URL friendly ID', async t => {
try {
await projectService.validateId('new name øæå');
} catch (err) {
t.is(err.message, '"value" must be URL friendly');
}
});
test.serial('should require unique ID', async t => {
try {
await projectService.validateId('default');
} catch (err) {
t.is(err.message, 'A project with this id already exists.');
}
});
test.serial('should update project', async t => {
const project = {
id: 'test-update',
name: 'New project',
description: 'Blah',
};
const updatedProject = {
id: 'test-update',
name: 'New name',
description: 'Blah longer desc',
};
await projectService.createProject(project, 'some-user');
await projectService.updateProject(updatedProject, 'some-user');
const readProject = await projectService.getProject(project.id);
t.is(updatedProject.name, readProject.name);
t.is(updatedProject.description, readProject.description);
});
test.serial('should give error when getting unkown project', async t => {
try {
await projectService.getProject('unknown');
} catch (err) {
t.is(err.message, 'No project found');
}
});

View File

@ -0,0 +1,84 @@
'use strict';
const test = require('ava');
const dbInit = require('../helpers/database-init');
const getLogger = require('../../fixtures/no-logger');
let stores;
let projectStore;
test.before(async () => {
const db = await dbInit('project_store_serial', getLogger);
stores = db.stores;
projectStore = stores.projectStore;
});
test.after(async () => {
await stores.db.destroy();
});
test.serial('should have default project', async t => {
const project = await projectStore.get('default');
t.assert(project);
t.is(project.id, 'default');
});
test.serial('should create new project', async t => {
const project = {
id: 'test',
name: 'New project',
description: 'Blah',
};
await projectStore.create(project);
const ret = await projectStore.get('test');
t.deepEqual(project.id, ret.id);
t.deepEqual(project.name, ret.name);
t.deepEqual(project.description, ret.description);
t.truthy(ret.createdAt);
});
test.serial('should delete project', async t => {
const project = {
id: 'test-delete',
name: 'New project',
description: 'Blah',
};
await projectStore.create(project);
await projectStore.delete(project.id);
try {
await projectStore.get(project.id);
} catch (err) {
t.is(err.message, 'No project found');
}
});
test.serial('should update project', async t => {
const project = {
id: 'test-update',
name: 'New project',
description: 'Blah',
};
const updatedProject = {
id: 'test-update',
name: 'New name',
description: 'Blah longer desc',
};
await projectStore.create(project);
await projectStore.update(updatedProject);
const readProject = await projectStore.get(project.id);
t.is(updatedProject.name, readProject.name);
t.is(updatedProject.description, readProject.description);
});
test.serial('should give error when getting unkown project', async t => {
try {
await projectStore.get('unknown');
} catch (err) {
t.is(err.message, 'No project found');
}
});

View File

@ -183,28 +183,11 @@
dependencies:
arrify "^1.0.1"
"@hapi/address@^4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@hapi/address/-/address-4.1.0.tgz#d60c5c0d930e77456fdcde2598e77302e2955e1d"
integrity sha512-SkszZf13HVgGmChdHo/PxchnSaCJ6cetVqLzyciudzZRT0jcOouIF/Q93mgjw8cce+D+4F4C1Z/WrfFN+O3VHQ==
dependencies:
"@hapi/hoek" "^9.0.0"
"@hapi/formula@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@hapi/formula/-/formula-2.0.0.tgz#edade0619ed58c8e4f164f233cda70211e787128"
integrity sha512-V87P8fv7PI0LH7LiVi8Lkf3x+KCO7pQozXRssAHNXXL9L1K+uyu4XypLXwxqVDKgyQai6qj3/KteNlrqDx4W5A==
"@hapi/hoek@^9.0.0":
version "9.0.4"
resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.0.4.tgz#e80ad4e8e8d2adc6c77d985f698447e8628b6010"
integrity sha512-EwaJS7RjoXUZ2cXXKZZxZqieGtc7RbvQhUy8FwDoMQtxWVi14tFjeFCYPZAM1mBCpOpiBpyaZbb9NeHc7eGKgw==
"@hapi/pinpoint@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@hapi/pinpoint/-/pinpoint-2.0.0.tgz#805b40d4dbec04fc116a73089494e00f073de8df"
integrity sha512-vzXR5MY7n4XeIvLpfl3HtE3coZYO4raKXW766R6DZw/6aLqR26iuZ109K7a0NtF2Db0jxqh7xz2AxkUwpUFybw==
"@hapi/topo@^5.0.0":
version "5.0.0"
resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-5.0.0.tgz#c19af8577fa393a06e9c77b60995af959be721e7"
@ -277,6 +260,23 @@
dependencies:
"@passport-next/passport-strategy" "1.x.x"
"@sideway/address@^4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.0.tgz#0b301ada10ac4e0e3fa525c90615e0b61a72b78d"
integrity sha512-wAH/JYRXeIFQRsxerIuLjgUu2Xszam+O5xKeatJ4oudShOOirfmsQ1D6LL54XOU2tizpCYku+s1wmU0SYdpoSA==
dependencies:
"@hapi/hoek" "^9.0.0"
"@sideway/formula@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.0.tgz#fe158aee32e6bd5de85044be615bc08478a0a13c"
integrity sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg==
"@sideway/pinpoint@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@sideway/pinpoint/-/pinpoint-2.0.0.tgz#cff8ffadc372ad29fd3f78277aeb29e632cc70df"
integrity sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==
"@sindresorhus/is@^0.14.0":
version "0.14.0"
resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea"
@ -3113,16 +3113,16 @@ istanbul-reports@^3.0.2:
html-escaper "^2.0.0"
istanbul-lib-report "^3.0.0"
joi@^17.2.0:
version "17.2.0"
resolved "https://registry.yarnpkg.com/joi/-/joi-17.2.0.tgz#81cba6c1145130482d57b6d50129c7ab0e7d8b0a"
integrity sha512-9ZC8pMSitNlenuwKARENBGVvvGYHNlwWe5rexo2WxyogaxCB5dNHAgFA1BJQ6nsJrt/jz1p5vSqDT6W6kciDDw==
joi@^17.3.0:
version "17.3.0"
resolved "https://registry.yarnpkg.com/joi/-/joi-17.3.0.tgz#f1be4a6ce29bc1716665819ac361dfa139fff5d2"
integrity sha512-Qh5gdU6niuYbUIUV5ejbsMiiFmBdw8Kcp8Buj2JntszCkCfxJ9Cz76OtHxOZMPXrt5810iDIXs+n1nNVoquHgg==
dependencies:
"@hapi/address" "^4.1.0"
"@hapi/formula" "^2.0.0"
"@hapi/hoek" "^9.0.0"
"@hapi/pinpoint" "^2.0.0"
"@hapi/topo" "^5.0.0"
"@sideway/address" "^4.1.0"
"@sideway/formula" "^3.0.0"
"@sideway/pinpoint" "^2.0.0"
js-string-escape@^1.0.1:
version "1.0.1"