From f1416ed5570d1e7e349bfd9da724ae52beef7d0a Mon Sep 17 00:00:00 2001 From: "Juan Picado @jotadeveloper" Date: Fri, 12 Oct 2018 11:07:55 +0200 Subject: [PATCH] feat: add support for profile cli command #392 (change password) (#1034) * feat: add support for profile cli command #392 - it allows to update password npm profile set password - display current profile npm profile get https://docs.npmjs.com/cli/profile * chore: update @verdaccio/types@4.0.0 * feat: add min password length on npm by defaul is min 7 characters, this might be configurable in the future. * chore: update verdaccio-htpasswd@1.0.1 * refactor: update unit test * refactor: provide friendly error for tfa request * test: api profile unit test * chore: fix eslint comment * test: update profile test * chore: set mim as 3 characters --- .gitignore | 4 +- package.json | 4 +- src/api/endpoint/api/user.js | 9 +- src/api/endpoint/api/v1/profile.js | 73 ++++++++++ src/api/endpoint/index.js | 2 + src/lib/auth-utils.js | 6 +- src/lib/auth.js | 27 +++- src/lib/constants.js | 10 ++ test/flow/plugins/auth/example.auth.plugin.js | 109 ++++++++------- test/unit/api/__api-helper.js | 42 +++++- test/unit/api/api.profile.spec.js | 130 ++++++++++++++++++ test/unit/api/api.spec.js | 4 +- .../partials/config/yaml/profile/profile.yaml | 27 ++++ types/index.js | 11 ++ yarn.lock | Bin 394298 -> 394298 bytes 15 files changed, 396 insertions(+), 62 deletions(-) create mode 100644 src/api/endpoint/api/v1/profile.js create mode 100644 test/unit/api/api.profile.spec.js create mode 100644 test/unit/partials/config/yaml/profile/profile.yaml diff --git a/.gitignore b/.gitignore index 847f397e8..7055bd4e8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,9 @@ build/ ### Test -test/unit/partials/store/test-jwt-storage/* +test/unit/partials/store/test-*-storage/* +.verdaccio-db.json +.sinopia-db.json ### !bin/verdaccio diff --git a/package.json b/package.json index 3aec2a1e0..a989b730f 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "request": "2.88.0", "semver": "5.5.1", "verdaccio-audit": "0.2.0", - "verdaccio-htpasswd": "0.2.2", + "verdaccio-htpasswd": "1.0.1", "verror": "1.10.0" }, "devDependencies": { @@ -56,7 +56,7 @@ "@commitlint/config-conventional": "7.1.2", "@material-ui/core": "3.1.0", "@material-ui/icons": "3.0.1", - "@verdaccio/types": "3.7.2", + "@verdaccio/types": "4.0.0", "babel-cli": "6.26.0", "babel-core": "6.26.3", "babel-eslint": "10.0.0", diff --git a/src/api/endpoint/api/user.js b/src/api/endpoint/api/user.js index d527b311c..60e09c3f1 100644 --- a/src/api/endpoint/api/user.js +++ b/src/api/endpoint/api/user.js @@ -7,8 +7,8 @@ import _ from 'lodash'; import Cookies from 'cookies'; import { ErrorCode } from '../../../lib/utils'; -import { API_MESSAGE, HTTP_STATUS } from '../../../lib/constants'; -import { createSessionToken, getApiToken, getAuthenticatedMessage } from '../../../lib/auth-utils'; +import { API_ERROR, API_MESSAGE, HTTP_STATUS } from '../../../lib/constants'; +import { createSessionToken, getApiToken, getAuthenticatedMessage, validatePassword } from '../../../lib/auth-utils'; import type { Config } from '@verdaccio/types'; import type { $Response, Router } from 'express'; @@ -35,6 +35,11 @@ export default function(route: Router, auth: IAuth, config: Config) { token, }); } else { + if (validatePassword(password) === false) { + // eslint-disable-next-line new-cap + return next(ErrorCode.getCode(HTTP_STATUS.BAD_REQUEST, API_ERROR.PASSWORD_SHORT())); + } + auth.add_user(name, password, async function(err, user) { if (err) { if (err.status >= HTTP_STATUS.BAD_REQUEST && err.status < HTTP_STATUS.INTERNAL_ERROR) { diff --git a/src/api/endpoint/api/v1/profile.js b/src/api/endpoint/api/v1/profile.js new file mode 100644 index 000000000..cff9c2231 --- /dev/null +++ b/src/api/endpoint/api/v1/profile.js @@ -0,0 +1,73 @@ +/** + * @prettier + */ + +// @flow + +import _ from 'lodash'; +import { API_ERROR, APP_ERROR, HTTP_STATUS, SUPPORT_ERRORS } from '../../../../lib/constants'; +import { ErrorCode } from '../../../../lib/utils'; +import { validatePassword } from '../../../../lib/auth-utils'; + +import type { $Response, Router } from 'express'; +import type { $NextFunctionVer, $RequestExtend, IAuth } from '../../../../../types'; + +export default function(route: Router, auth: IAuth) { + const buildProfile = name => ({ + tfa: false, + name, + email: '', + email_verified: false, + created: '', + updated: '', + cidr_whitelist: null, + fullname: '', + }); + + route.get('/-/npm/v1/user', function(req: $RequestExtend, res: $Response, next: $NextFunctionVer) { + if (_.isNil(req.remote_user.name) === false) { + return next(buildProfile(req.remote_user.name)); + } + + res.status(HTTP_STATUS.UNAUTHORIZED); + return next({ + message: API_ERROR.MUST_BE_LOGGED, + }); + }); + + route.post('/-/npm/v1/user', function(req: $RequestExtend, res: $Response, next: $NextFunctionVer) { + if (_.isNil(req.remote_user.name)) { + res.status(HTTP_STATUS.UNAUTHORIZED); + return next({ + message: API_ERROR.MUST_BE_LOGGED, + }); + } + + const { password, tfa } = req.body; + const { name } = req.remote_user; + + if (_.isNil(password) === false) { + if (validatePassword(password.new) === false) { + /* eslint new-cap:off */ + return next(ErrorCode.getCode(HTTP_STATUS.UNAUTHORIZED, API_ERROR.PASSWORD_SHORT())); + /* eslint new-cap:off */ + } + + auth.changePassword(name, password.old, password.new, (err, isUpdated) => { + if (_.isNull(err) === false) { + return next(ErrorCode.getCode(err.status, err.message) || ErrorCode.getConflict(err.message)); + } + + if (isUpdated) { + return next(buildProfile(req.remote_user.name)); + } else { + return next(ErrorCode.getInternalError(API_ERROR.INTERNAL_SERVER_ERROR)); + } + }); + } else if (_.isNil(tfa) === false) { + return next(ErrorCode.getCode(HTTP_STATUS.SERVICE_UNAVAILABLE, SUPPORT_ERRORS.TFA_DISABLED)); + } else { + return next(ErrorCode.getCode(HTTP_STATUS.INTERNAL_ERROR, APP_ERROR.PROFILE_ERROR)); + } + }); +} diff --git a/src/api/endpoint/index.js b/src/api/endpoint/index.js index 1805d0498..94e99e287 100644 --- a/src/api/endpoint/index.js +++ b/src/api/endpoint/index.js @@ -15,6 +15,7 @@ import distTags from './api/dist-tags'; import publish from './api/publish'; import search from './api/search'; import pkg from './api/package'; +import profile from './api/v1/profile'; const { match, validateName, validatePackage, encodeScopePackage, antiLoop } = require('../middleware'); @@ -48,6 +49,7 @@ export default function(config: Config, auth: IAuth, storage: IStorageHandler) { // for "npm whoami" whoami(app); pkg(app, auth, storage, config); + profile(app, auth); search(app, auth, storage); user(app, auth, config); distTags(app, auth, storage); diff --git a/src/lib/auth-utils.js b/src/lib/auth-utils.js index d90ad3596..da8a49491 100644 --- a/src/lib/auth-utils.js +++ b/src/lib/auth-utils.js @@ -5,12 +5,16 @@ import _ from 'lodash'; import { convertPayloadToBase64, ErrorCode } from './utils'; -import { API_ERROR, HTTP_STATUS, ROLES, TIME_EXPIRATION_7D, TOKEN_BASIC, TOKEN_BEARER, CHARACTER_ENCODING } from './constants'; +import { API_ERROR, HTTP_STATUS, ROLES, TIME_EXPIRATION_7D, TOKEN_BASIC, TOKEN_BEARER, CHARACTER_ENCODING, DEFAULT_MIN_LIMIT_PASSWORD } from './constants'; import type { RemoteUser, Package, Callback, Config, Security, APITokenOptions, JWTOptions } from '@verdaccio/types'; import type { CookieSessionToken, IAuthWebUI, AuthMiddlewarePayload, AuthTokenHeader, BasicPayload } from '../../types'; import { aesDecrypt, verifyPayload } from './crypto-utils'; +export function validatePassword(password: string, minLength: number = DEFAULT_MIN_LIMIT_PASSWORD) { + return typeof password === 'string' && password.length >= minLength; +} + /** * Create a RemoteUser object * @return {Object} { name: xx, pluginGroups: [], real_groups: [] } diff --git a/src/lib/auth.js b/src/lib/auth.js index 935bf866f..d5f02f4f5 100644 --- a/src/lib/auth.js +++ b/src/lib/auth.js @@ -5,7 +5,7 @@ import _ from 'lodash'; -import { API_ERROR, TOKEN_BASIC, TOKEN_BEARER } from './constants'; +import { API_ERROR, SUPPORT_ERRORS, TOKEN_BASIC, TOKEN_BEARER } from './constants'; import loadPlugin from '../lib/plugin-loader'; import { aesEncrypt, signPayload } from './crypto-utils'; import { @@ -60,6 +60,31 @@ class Auth implements IAuth { this.plugins.push(getDefaultPlugins()); } + changePassword(username: string, password: string, newPassword: string, cb: Callback) { + const validPlugins = _.filter(this.plugins, plugin => _.isFunction(plugin.changePassword)); + + if (_.isEmpty(validPlugins)) { + return cb(ErrorCode.getInternalError(SUPPORT_ERRORS.PLUGIN_MISSING_INTERFACE)); + } + + for (const plugin of validPlugins) { + this.logger.trace({ username }, 'updating password for @{username}'); + plugin.changePassword(username, password, newPassword, (err, profile) => { + if (err) { + this.logger.error( + { username, err }, + `An error has been produced + updating the password for @{username}. Error: @{err.message}` + ); + return cb(err); + } + + this.logger.trace({ username }, 'updated password for @{username} was successful'); + return cb(null, profile); + }); + } + } + authenticate(username: string, password: string, cb: Callback) { const plugins = this.plugins.slice(0); const self = this; diff --git a/src/lib/constants.js b/src/lib/constants.js index d784b5d9b..e898f1a95 100644 --- a/src/lib/constants.js +++ b/src/lib/constants.js @@ -10,6 +10,7 @@ export const DEFAULT_DOMAIN: string = 'localhost'; export const TIME_EXPIRATION_24H: string = '24h'; export const TIME_EXPIRATION_7D: string = '7d'; export const DIST_TAGS = 'dist-tags'; +export const DEFAULT_MIN_LIMIT_PASSWORD: number = 3; export const keyPem = 'verdaccio-key.pem'; export const certPem = 'verdaccio-cert.pem'; @@ -87,7 +88,15 @@ export const API_MESSAGE = { LOGGED_OUT: 'Logged out', }; +export const SUPPORT_ERRORS = { + PLUGIN_MISSING_INTERFACE: 'the plugin does not provide implementation of the requested feature', + TFA_DISABLED: 'the two-factor authentication is not yet supported', +}; + export const API_ERROR = { + PASSWORD_SHORT: (passLength: number = DEFAULT_MIN_LIMIT_PASSWORD) => + `The provided password is too short. Please pick a password longer than ${passLength} characters.`, + MUST_BE_LOGGED: 'You must be logged in to publish packages.', PLUGIN_ERROR: 'bug in the auth plugin system', CONFIG_BAD_FORMAT: 'config file must be an object', BAD_USERNAME_PASSWORD: 'bad username/password, access denied', @@ -116,6 +125,7 @@ export const API_ERROR = { export const APP_ERROR = { CONFIG_NOT_VALID: 'CONFIG: it does not look like a valid config file', + PROFILE_ERROR: 'profile unexpected error', }; export const DEFAULT_NO_README = 'ERROR: No README data found!'; diff --git a/test/flow/plugins/auth/example.auth.plugin.js b/test/flow/plugins/auth/example.auth.plugin.js index 9ece36b22..3136f95b9 100644 --- a/test/flow/plugins/auth/example.auth.plugin.js +++ b/test/flow/plugins/auth/example.auth.plugin.js @@ -1,3 +1,7 @@ +/** + * @prettier + */ + // @flow // this file is not aim to be tested, just to check flow definitions @@ -5,92 +9,93 @@ import Config from '../../../../src/lib/config'; import LoggerApi from '../../../../src/lib/logger'; -import type { - Config as AppConfig, - PackageAccess, - IPluginAuth, - RemoteUser, - Logger, - PluginOptions - } from '@verdaccio/types'; +import type { Config as AppConfig, PackageAccess, IPluginAuth, RemoteUser, Logger, PluginOptions } from '@verdaccio/types'; class ExampleAuthPlugin implements IPluginAuth { - config: AppConfig; - logger: Logger; + config: AppConfig; + logger: Logger; - constructor(config: AppConfig, options: PluginOptions) { - this.config = config; - this.logger = options.logger; - } + constructor(config: AppConfig, options: PluginOptions) { + this.config = config; + this.logger = options.logger; + } - adduser(user: string, password: string, cb: verdaccio$Callback): void { - cb(); - } + adduser(user: string, password: string, cb: verdaccio$Callback): void { + cb(); + } - authenticate(user: string, password: string, cb: verdaccio$Callback): void { - cb(); - } + changePassword(username, password, newPassword, cb: verdaccio$Callback): void { + cb(); + } + + authenticate(user: string, password: string, cb: verdaccio$Callback): void { + cb(); + } allow_access(user: RemoteUser, pkg: PackageAccess, cb: verdaccio$Callback): void { - cb(); - } + cb(); + } allow_publish(user: RemoteUser, pkg: PackageAccess, cb: verdaccio$Callback): void { - cb(); - } + cb(); + } } type SubTypePackageAccess = PackageAccess & { - sub?: boolean -} + sub?: boolean, +}; class ExampleAuthCustomPlugin implements IPluginAuth { - config: AppConfig; - logger: Logger; + config: AppConfig; + logger: Logger; - constructor(config: AppConfig, options: PluginOptions) { - this.config = config; - this.logger = options.logger; - } + constructor(config: AppConfig, options: PluginOptions) { + this.config = config; + this.logger = options.logger; + } - adduser(user: string, password: string, cb: verdaccio$Callback): void { - cb(); - } + adduser(user: string, password: string, cb: verdaccio$Callback): void { + cb(); + } - authenticate(user: string, password: string, cb: verdaccio$Callback): void { - cb(); - } + changePassword(username, password, newPassword, cb: verdaccio$Callback): void { + cb(); + } + + authenticate(user: string, password: string, cb: verdaccio$Callback): void { + cb(); + } allow_access(user: RemoteUser, pkg: SubTypePackageAccess, cb: verdaccio$Callback): void { - cb(); - } + cb(); + } allow_publish(user: RemoteUser, pkg: SubTypePackageAccess, cb: verdaccio$Callback): void { - cb(); - } + cb(); + } } const config1: AppConfig = new Config({ - storage: './storage', - self_path: '/home/sotrage' + storage: './storage', + self_path: '/home/sotrage', }); const options: PluginOptions = { - config: config1, - logger: LoggerApi.logger.child() -} + config: config1, + logger: LoggerApi.logger.child(), +}; const auth = new ExampleAuthPlugin(config1, options); const authSub = new ExampleAuthCustomPlugin(config1, options); const remoteUser: RemoteUser = { - groups: [], - real_groups: [], - name: 'test' + groups: [], + real_groups: [], + name: 'test', }; auth.authenticate('user', 'pass', () => {}); auth.allow_access(remoteUser, {}, () => {}); auth.allow_publish(remoteUser, {}, () => {}); authSub.authenticate('user', 'pass', () => {}); -authSub.allow_access(remoteUser, {sub: true}, () => {}); -authSub.allow_publish(remoteUser, {sub: true}, () => {}); \ No newline at end of file +authSub.allow_access(remoteUser, { sub: true }, () => {}); +authSub.allow_publish(remoteUser, { sub: true }, () => {}); diff --git a/test/unit/api/__api-helper.js b/test/unit/api/__api-helper.js index e84903d0c..df4159561 100644 --- a/test/unit/api/__api-helper.js +++ b/test/unit/api/__api-helper.js @@ -22,7 +22,7 @@ export function getPackage( export function addUser(request: any, user: string, credentials: any, statusCode: number = HTTP_STATUS.CREATED) { // $FlowFixMe - return new Promise((resolve, reject) => { + return new Promise((resolve) => { request.put(`/-/user/org.couchdb.user:${user}`) .send(credentials) .expect(HEADER_TYPE.CONTENT_TYPE, HEADERS.JSON_CHARSET) @@ -32,3 +32,43 @@ export function addUser(request: any, user: string, credentials: any, }); }); } + +export async function getNewToken(request: any, credentials: any) { + return new Promise(async (resolve) => { + const [err, res] = await + addUser(request, credentials.name, credentials); + expect(err).toBeNull(); + const {token, ok} = res.body; + expect(ok).toBeDefined(); + expect(token).toBeDefined(); + expect(typeof token).toBe('string'); + resolve(token); + }); +} + +export function getProfile(request: any, token: string, statusCode: number = HTTP_STATUS.OK) { + // $FlowFixMe + return new Promise((resolve) => { + request.get(`/-/npm/v1/user`) + .set('authorization', `Bearer ${token}`) + .expect(HEADER_TYPE.CONTENT_TYPE, HEADERS.JSON_CHARSET) + .expect(statusCode) + .end(function(err, res) { + return resolve([err, res]); + }); + }); +} + +export function postProfile(request: any, body: any, token: string, statusCode: number = HTTP_STATUS.OK) { + // $FlowFixMe + return new Promise((resolve) => { + request.post(`/-/npm/v1/user`) + .send(body) + .set('authorization', `Bearer ${token}`) + .expect(HEADER_TYPE.CONTENT_TYPE, HEADERS.JSON_CHARSET) + .expect(statusCode) + .end(function(err, res) { + return resolve([err, res]); + }); + }); +} diff --git a/test/unit/api/api.profile.spec.js b/test/unit/api/api.profile.spec.js new file mode 100644 index 000000000..002f771a8 --- /dev/null +++ b/test/unit/api/api.profile.spec.js @@ -0,0 +1,130 @@ +// @flow + +import request from 'supertest'; +import _ from 'lodash'; +import path from 'path'; +import rimraf from 'rimraf'; + +import Config from '../../../src/lib/config'; +import endPointAPI from '../../../src/api/index'; +import {mockServer} from './mock'; +import {parseConfigFile} from '../../../src/lib/utils'; +import {parseConfigurationFile} from '../__helper'; +import {getNewToken, getProfile, postProfile} from './__api-helper'; +import {setup} from '../../../src/lib/logger'; +import {API_ERROR, HTTP_STATUS, SUPPORT_ERRORS} from '../../../src/lib/constants'; + +setup([]); + +const parseConfigurationProfile = () => { + return parseConfigurationFile(`profile/profile`); +}; + + +describe('endpoint user profile', () => { + let config; + let app; + let mockRegistry; + + beforeAll(function(done) { + const store = path.join(__dirname, '../partials/store/test-profile-storage'); + const mockServerPort = 55544; + rimraf(store, async () => { + const parsedConfig = parseConfigFile(parseConfigurationProfile()); + const configForTest = _.clone(parsedConfig); + configForTest.storage = store; + configForTest.auth = { + htpasswd: { + file: './test-profile-storage/.htpasswd' + } + }; + configForTest.self_path = store; + config = new Config(configForTest); + app = await endPointAPI(config); + mockRegistry = await mockServer(mockServerPort).init(); + done(); + }); + }); + + afterAll(function(done) { + mockRegistry[0].stop(); + done(); + }); + + test('should fetch a profile of logged user', async (done) => { + const credentials = { name: 'JotaJWT', password: 'secretPass' }; + const token = await getNewToken(request(app), credentials); + const [err1, res1] = await getProfile(request(app), token); + + expect(err1).toBeNull(); + expect(res1.body.name).toBe(credentials.name); + done(); + }); + + describe('change password', () => { + test('should change password successfully', async (done) => { + const credentials = { name: 'userTest2000', password: 'secretPass000' }; + const body = { + password: { + new: '12345678', + old: credentials.password, + } + }; + const token = await getNewToken(request(app), credentials); + const [err1, res1] = await postProfile(request(app), body, token); + + expect(err1).toBeNull(); + expect(res1.body.name).toBe(credentials.name); + done(); + }); + + test('should change password is too short', async (done) => { + const credentials = { name: 'userTest2001', password: 'secretPass001' }; + const body = { + password: { + new: 'p1', + old: credentials.password, + } + }; + const token = await getNewToken(request(app), credentials); + const [, resp] = await postProfile(request(app), body, token, HTTP_STATUS.UNAUTHORIZED); + + expect(resp.error).not.toBeNull(); + expect(resp.error.text).toMatch(API_ERROR.PASSWORD_SHORT()); + done(); + }); + }); + + describe('change tfa', () => { + test('should report TFA is disabled', async (done) => { + const credentials = { name: 'userTest2002', password: 'secretPass002' }; + const body = { + tfa: {} + }; + const token = await getNewToken(request(app), credentials); + const [, resp] = await postProfile(request(app), body, token, HTTP_STATUS.SERVICE_UNAVAILABLE); + + expect(resp.error).not.toBeNull(); + expect(resp.error.text).toMatch(SUPPORT_ERRORS.TFA_DISABLED); + done(); + }); + }); + + describe('error handling', () => { + test('should forbid to fetch a profile with invalid token', async (done) => { + const [, resp] = await getProfile(request(app), `fakeToken`, HTTP_STATUS.UNAUTHORIZED); + + expect(resp.error).not.toBeNull(); + expect(resp.error.text).toMatch(API_ERROR.MUST_BE_LOGGED); + done(); + }); + + test('should forbid to update a profile with invalid token', async (done) => { + const [, resp] = await postProfile(request(app), {}, `fakeToken`, HTTP_STATUS.UNAUTHORIZED); + + expect(resp.error).not.toBeNull(); + expect(resp.error.text).toMatch(API_ERROR.MUST_BE_LOGGED); + done(); + }); + }); +}); diff --git a/test/unit/api/api.spec.js b/test/unit/api/api.spec.js index dd3efb7c2..734844861 100644 --- a/test/unit/api/api.spec.js +++ b/test/unit/api/api.spec.js @@ -186,7 +186,7 @@ describe('endpoint unit test', () => { } expect(res.body.error).toBeDefined(); - expect(res.body.error).toMatch(/username and password is required/); + expect(res.body.error).toMatch('username and password is required'); done(); }); }); @@ -208,7 +208,7 @@ describe('endpoint unit test', () => { expect(res.body.error).toBeDefined(); //FIXME: message is not 100% accurate - expect(res.body.error).toMatch(/username and password is required/); + expect(res.body.error).toMatch(API_ERROR.PASSWORD_SHORT()); done(); }); }); diff --git a/test/unit/partials/config/yaml/profile/profile.yaml b/test/unit/partials/config/yaml/profile/profile.yaml new file mode 100644 index 000000000..93ab64359 --- /dev/null +++ b/test/unit/partials/config/yaml/profile/profile.yaml @@ -0,0 +1,27 @@ +storage: ./storage +plugins: ./plugins + +web: + title: Verdaccio + +auth: + htpasswd: + file: ./htpasswd +uplinks: + npmjs: + url: https://registry.npmjs.org/ +security: + api: + jwt: + sign: + expiresIn: 10m + notBefore: 0 +packages: + '@*/*': + access: $authenticated + publish: $authenticated + '**': + access: $authenticated + publish: $authenticated +logs: + - { type: stdout, format: pretty, level: http } diff --git a/types/index.js b/types/index.js index 7dd745e90..796212d31 100644 --- a/types/index.js +++ b/types/index.js @@ -67,6 +67,17 @@ export type Utils = { semverSort: (keys: Array) => Array; } +export type Profile = { + tfa: boolean; + name: string; + email: string; + email_verified: string; + created: string; + updated: string; + cidr_whitelist: any; + fullname: string; +} + export type $RequestExtend = $Request & {remote_user?: any} export type $ResponseExtend = $Response & {cookies?: any} export type $NextFunctionVer = NextFunction & mixed; diff --git a/yarn.lock b/yarn.lock index 9d567a67bf7f4fea2cabb75dff83aec5524b4855..672af065cc241b2b3c5a88aa20d3c58217ffd437 100644 GIT binary patch delta 185 zcmdnhA+f7NVuOeUn~9!*o`KTzjsID!CvPz4o}6I8X9O10D@m_XHnub{O-(X2H8)B! zHZnG}G)+u3OEs`CGd4CbG%+?evrJC1OfpPLn*7jGxOuZh`(_J9AZ7w$W*}zSzS)8` zW5V=_){G9*dylhnO;0_^Draa2w9*i4d`enOUNNv1OuBvWdA-O0rR6nz=!$ ckwucFiD8;ql8I&7bjK`4@$IKivgTU>00!n`amwU}mcq@OE!sC*Faj|X z5HkZY%l6F{tQixgHxx2COz%C;$~8UpB&(dEfu0c%f{Zt_FgH$1H8Dy~u`o_cGcZmu lGBZdrGBmL;wlpy`Gd4{#HZn-INSb~ypHY1K>65JaRsh{FJc