1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-19 01:17:18 +02:00

Merge main

This commit is contained in:
sjaanus 2022-08-26 09:09:18 +00:00
commit 709c142a87
47 changed files with 903 additions and 281 deletions

View File

@ -1,7 +1,7 @@
{ {
"name": "unleash-server", "name": "unleash-server",
"description": "Unleash is an enterprise ready feature toggles service. It provides different strategies for handling feature toggles.", "description": "Unleash is an enterprise ready feature toggles service. It provides different strategies for handling feature toggles.",
"version": "4.15.0-beta.2", "version": "4.15.0-beta.4",
"keywords": [ "keywords": [
"unleash", "unleash",
"feature toggle", "feature toggle",
@ -127,6 +127,7 @@
"ts-toolbelt": "^9.6.0", "ts-toolbelt": "^9.6.0",
"type-is": "^1.6.18", "type-is": "^1.6.18",
"unleash-client": "3.15.0", "unleash-client": "3.15.0",
"unleash-frontend": "4.14.8",
"uuid": "^8.3.2" "uuid": "^8.3.2"
}, },
"devDependencies": { "devDependencies": {
@ -150,8 +151,8 @@
"@types/supertest": "2.0.12", "@types/supertest": "2.0.12",
"@types/type-is": "1.6.3", "@types/type-is": "1.6.3",
"@types/uuid": "8.3.4", "@types/uuid": "8.3.4",
"@typescript-eslint/eslint-plugin": "5.34.0", "@typescript-eslint/eslint-plugin": "5.35.1",
"@typescript-eslint/parser": "5.34.0", "@typescript-eslint/parser": "5.35.1",
"copyfiles": "2.4.1", "copyfiles": "2.4.1",
"coveralls": "3.1.1", "coveralls": "3.1.1",
"del-cli": "5.0.0", "del-cli": "5.0.0",
@ -176,7 +177,7 @@
"ts-jest": "27.1.5", "ts-jest": "27.1.5",
"ts-node": "10.9.1", "ts-node": "10.9.1",
"tsc-watch": "5.0.3", "tsc-watch": "5.0.3",
"typescript": "4.7.4" "typescript": "4.8.2"
}, },
"resolutions": { "resolutions": {
"async": "^3.2.4", "async": "^3.2.4",

View File

@ -11,7 +11,7 @@ services:
- 5432:5432 - 5432:5432
pgadmin: pgadmin:
image: dpage/pgadmin4:6.12 image: dpage/pgadmin4:6.13
environment: environment:
PGADMIN_DEFAULT_EMAIL: 'admin@admin.com' PGADMIN_DEFAULT_EMAIL: 'admin@admin.com'
PGADMIN_DEFAULT_PASSWORD: 'admin' PGADMIN_DEFAULT_PASSWORD: 'admin'

View File

@ -62,10 +62,30 @@ Object {
}, },
"eventHook": undefined, "eventHook": undefined,
"experimental": Object { "experimental": Object {
"batchMetrics": false, "externalResolver": Object {
"embedProxy": false, "isEnabled": [Function],
},
"flags": Object {
"ENABLE_DARK_MODE_SUPPORT": false,
"anonymiseEventLog": false,
"batchMetrics": false,
"embedProxy": false,
},
}, },
"frontendApiOrigins": Array [], "flagResolver": FlagResolver {
"experiments": Object {
"ENABLE_DARK_MODE_SUPPORT": false,
"anonymiseEventLog": false,
"batchMetrics": false,
"embedProxy": false,
},
"externalResolver": Object {
"isEnabled": [Function],
},
},
"frontendApiOrigins": Array [
"*",
],
"getLogger": [Function], "getLogger": [Function],
"import": Object { "import": Object {
"dropBeforeImport": false, "dropBeforeImport": false,
@ -106,6 +126,7 @@ Object {
"ui": Object { "ui": Object {
"flags": Object { "flags": Object {
"E": true, "E": true,
"ENABLE_DARK_MODE_SUPPORT": false,
}, },
}, },
"versionCheck": Object { "versionCheck": Object {

View File

@ -70,16 +70,13 @@ export default async function getApp(
} }
if ( if (
config.experimental.embedProxy && config.experimental.flags.embedProxy &&
config.frontendApiOrigins.length > 0 config.frontendApiOrigins.length > 0
) { ) {
// Support CORS preflight requests for the frontend endpoints. // Support CORS preflight requests for the frontend endpoints.
// Preflight requests should not have Authorization headers, // Preflight requests should not have Authorization headers,
// so this must be handled before the API token middleware. // so this must be handled before the API token middleware.
app.options( app.options('/api/frontend*', corsOriginMiddleware(services));
'/api/frontend*',
corsOriginMiddleware(config.frontendApiOrigins),
);
} }
switch (config.authentication.type) { switch (config.authentication.type) {

View File

@ -403,3 +403,28 @@ test('Environment variables for client features caching takes priority over opti
expect(config.clientFeatureCaching.enabled).toBe(true); expect(config.clientFeatureCaching.enabled).toBe(true);
expect(config.clientFeatureCaching.maxAge).toBe(120); expect(config.clientFeatureCaching.maxAge).toBe(120);
}); });
test('Environment variables for frontend CORS origins takes priority over options', async () => {
const create = (frontendApiOrigins?): string[] => {
return createConfig({
frontendApiOrigins,
}).frontendApiOrigins;
};
expect(create()).toEqual(['*']);
expect(create([])).toEqual([]);
expect(create(['*'])).toEqual(['*']);
expect(create(['https://example.com'])).toEqual(['https://example.com']);
expect(() => create(['a'])).toThrow('Invalid origin: a');
process.env.UNLEASH_FRONTEND_API_ORIGINS = '';
expect(create()).toEqual([]);
process.env.UNLEASH_FRONTEND_API_ORIGINS = '*';
expect(create()).toEqual(['*']);
process.env.UNLEASH_FRONTEND_API_ORIGINS = 'https://example.com, *';
expect(create()).toEqual(['https://example.com', '*']);
process.env.UNLEASH_FRONTEND_API_ORIGINS = 'b';
expect(() => create(['a'])).toThrow('Invalid origin: b');
delete process.env.UNLEASH_FRONTEND_API_ORIGINS;
expect(create()).toEqual(['*']);
});

View File

@ -34,11 +34,16 @@ import {
parseEnvVarNumber, parseEnvVarNumber,
parseEnvVarStrings, parseEnvVarStrings,
} from './util/parseEnvVar'; } from './util/parseEnvVar';
import { IExperimentalOptions } from './experimental'; import {
defaultExperimentalOptions,
IExperimentalOptions,
} from './types/experimental';
import { import {
DEFAULT_SEGMENT_VALUES_LIMIT, DEFAULT_SEGMENT_VALUES_LIMIT,
DEFAULT_STRATEGY_SEGMENTS_LIMIT, DEFAULT_STRATEGY_SEGMENTS_LIMIT,
} from './util/segments'; } from './util/segments';
import FlagResolver from './util/flag-resolver';
import { validateOrigins } from './util/validateOrigin';
const safeToUpper = (s: string) => (s ? s.toUpperCase() : s); const safeToUpper = (s: string) => (s ? s.toUpperCase() : s);
@ -55,15 +60,12 @@ function mergeAll<T>(objects: Partial<T>[]): T {
function loadExperimental(options: IUnleashOptions): IExperimentalOptions { function loadExperimental(options: IUnleashOptions): IExperimentalOptions {
return { return {
...defaultExperimentalOptions,
...options.experimental, ...options.experimental,
embedProxy: parseEnvVarBoolean( flags: {
process.env.UNLEASH_EXPERIMENTAL_EMBED_PROXY, ...defaultExperimentalOptions.flags,
Boolean(options.experimental?.embedProxy), ...options.experimental?.flags,
), },
batchMetrics: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_BATCH_METRICS,
Boolean(options.experimental?.batchMetrics),
),
}; };
} }
@ -102,6 +104,7 @@ function loadUI(options: IUnleashOptions): IUIConfig {
ui.flags = { ui.flags = {
E: true, E: true,
ENABLE_DARK_MODE_SUPPORT: false,
}; };
return mergeAll([uiO, ui]); return mergeAll([uiO, ui]);
} }
@ -309,6 +312,20 @@ const parseCspEnvironmentVariables = (): ICspDomainConfig => {
}; };
}; };
const parseFrontendApiOrigins = (options: IUnleashOptions): string[] => {
const frontendApiOrigins = parseEnvVarStrings(
process.env.UNLEASH_FRONTEND_API_ORIGINS,
options.frontendApiOrigins || ['*'],
);
const error = validateOrigins(frontendApiOrigins);
if (error) {
throw new Error(error);
}
return frontendApiOrigins;
};
export function createConfig(options: IUnleashOptions): IUnleashConfig { export function createConfig(options: IUnleashOptions): IUnleashConfig {
let extraDbOptions = {}; let extraDbOptions = {};
@ -375,6 +392,7 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
]); ]);
const experimental = loadExperimental(options); const experimental = loadExperimental(options);
const flagResolver = new FlagResolver(experimental);
const ui = loadUI(options); const ui = loadUI(options);
@ -417,10 +435,6 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
DEFAULT_STRATEGY_SEGMENTS_LIMIT, DEFAULT_STRATEGY_SEGMENTS_LIMIT,
); );
const frontendApiOrigins =
options.frontendApiOrigins ||
parseEnvVarStrings(process.env.UNLEASH_FRONTEND_API_ORIGINS, []);
const clientFeatureCaching = loadClientCachingOptions(options); const clientFeatureCaching = loadClientCachingOptions(options);
return { return {
@ -434,6 +448,7 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
ui, ui,
import: importSetting, import: importSetting,
experimental, experimental,
flagResolver,
email, email,
secureHeaders, secureHeaders,
enableOAS, enableOAS,
@ -445,7 +460,7 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
eventBus: new EventEmitter(), eventBus: new EventEmitter(),
environmentEnableOverrides, environmentEnableOverrides,
additionalCspAllowedDomains, additionalCspAllowedDomains,
frontendApiOrigins, frontendApiOrigins: parseFrontendApiOrigins(options),
inlineSegmentConstraints, inlineSegmentConstraints,
segmentValuesLimit, segmentValuesLimit,
strategySegmentsLimit, strategySegmentsLimit,

View File

@ -45,16 +45,8 @@ export class AccessStore implements IAccessStore {
private db: Knex; private db: Knex;
private enableUserGroupPermissions: boolean; constructor(db: Knex, eventBus: EventEmitter, getLogger: Function) {
constructor(
db: Knex,
eventBus: EventEmitter,
getLogger: Function,
enableUserGroupPermissions: boolean,
) {
this.db = db; this.db = db;
this.enableUserGroupPermissions = enableUserGroupPermissions;
this.logger = getLogger('access-store.ts'); this.logger = getLogger('access-store.ts');
this.timer = (action: string) => this.timer = (action: string) =>
metricsHelper.wrapTimer(eventBus, DB_TIME, { metricsHelper.wrapTimer(eventBus, DB_TIME, {
@ -133,27 +125,21 @@ export class AccessStore implements IAccessStore {
.join(`${T.PERMISSIONS} AS p`, 'p.id', 'rp.permission_id') .join(`${T.PERMISSIONS} AS p`, 'p.id', 'rp.permission_id')
.where('ur.user_id', '=', userId); .where('ur.user_id', '=', userId);
if (this.enableUserGroupPermissions) { userPermissionQuery = userPermissionQuery.union((db) => {
userPermissionQuery = userPermissionQuery.union((db) => { db.select(
db.select( 'project',
'project', 'permission',
'permission', 'environment',
'environment', 'p.type',
'p.type', 'gr.role_id',
'gr.role_id', )
) .from<IPermissionRow>(`${T.GROUP_USER} AS gu`)
.from<IPermissionRow>(`${T.GROUP_USER} AS gu`) .join(`${T.GROUPS} AS g`, 'g.id', 'gu.group_id')
.join(`${T.GROUPS} AS g`, 'g.id', 'gu.group_id') .join(`${T.GROUP_ROLE} AS gr`, 'gu.group_id', 'gr.group_id')
.join(`${T.GROUP_ROLE} AS gr`, 'gu.group_id', 'gr.group_id') .join(`${T.ROLE_PERMISSION} AS rp`, 'rp.role_id', 'gr.role_id')
.join( .join(`${T.PERMISSIONS} AS p`, 'p.id', 'rp.permission_id')
`${T.ROLE_PERMISSION} AS rp`, .where('gu.user_id', '=', userId);
'rp.role_id', });
'gr.role_id',
)
.join(`${T.PERMISSIONS} AS p`, 'p.id', 'rp.permission_id')
.where('gu.user_id', '=', userId);
});
}
const rows = await userPermissionQuery; const rows = await userPermissionQuery;
stopTimer(); stopTimer();
return rows.map(this.mapUserPermission); return rows.map(this.mapUserPermission);

View File

@ -57,12 +57,7 @@ export const createStores = (
tagStore: new TagStore(db, eventBus, getLogger), tagStore: new TagStore(db, eventBus, getLogger),
tagTypeStore: new TagTypeStore(db, eventBus, getLogger), tagTypeStore: new TagTypeStore(db, eventBus, getLogger),
addonStore: new AddonStore(db, eventBus, getLogger), addonStore: new AddonStore(db, eventBus, getLogger),
accessStore: new AccessStore( accessStore: new AccessStore(db, eventBus, getLogger),
db,
eventBus,
getLogger,
config?.experimental?.userGroups,
),
apiTokenStore: new ApiTokenStore(db, eventBus, getLogger), apiTokenStore: new ApiTokenStore(db, eventBus, getLogger),
resetTokenStore: new ResetTokenStore(db, eventBus, getLogger), resetTokenStore: new ResetTokenStore(db, eventBus, getLogger),
sessionStore: new SessionStore(db, eventBus, getLogger), sessionStore: new SessionStore(db, eventBus, getLogger),

View File

@ -1,12 +0,0 @@
export interface IExperimentalOptions {
metricsV2?: IExperimentalToggle;
clientFeatureMemoize?: IExperimentalToggle;
userGroups?: boolean;
anonymiseEventLog?: boolean;
embedProxy?: boolean;
batchMetrics?: boolean;
}
export interface IExperimentalToggle {
enabled: boolean;
}

View File

@ -42,7 +42,8 @@ const apiAccessMiddleware = (
if ( if (
(apiUser.type === CLIENT && !isClientApi(req)) || (apiUser.type === CLIENT && !isClientApi(req)) ||
(apiUser.type === FRONTEND && !isProxyApi(req)) || (apiUser.type === FRONTEND && !isProxyApi(req)) ||
(apiUser.type === FRONTEND && !experimental.embedProxy) (apiUser.type === FRONTEND &&
!experimental.flags.embedProxy)
) { ) {
res.status(403).send({ message: TOKEN_TYPE_ERROR_MESSAGE }); res.status(403).send({ message: TOKEN_TYPE_ERROR_MESSAGE });
return; return;

View File

@ -1,4 +1,21 @@
import { allowRequestOrigin } from './cors-origin-middleware'; import { allowRequestOrigin } from './cors-origin-middleware';
import FakeSettingStore from '../../test/fixtures/fake-setting-store';
import SettingService from '../services/setting-service';
import { createTestConfig } from '../../test/config/test-config';
import FakeEventStore from '../../test/fixtures/fake-event-store';
import { randomId } from '../util/random-id';
import { frontendSettingsKey } from '../types/settings/frontend-settings';
const createSettingService = (frontendApiOrigins: string[]): SettingService => {
const config = createTestConfig({ frontendApiOrigins });
const stores = {
settingStore: new FakeSettingStore(),
eventStore: new FakeEventStore(),
};
return new SettingService(stores, config);
};
test('allowRequestOrigin', () => { test('allowRequestOrigin', () => {
const dotCom = 'https://example.com'; const dotCom = 'https://example.com';
@ -16,3 +33,54 @@ test('allowRequestOrigin', () => {
expect(allowRequestOrigin(dotCom, [dotOrg, '*'])).toEqual(true); expect(allowRequestOrigin(dotCom, [dotOrg, '*'])).toEqual(true);
expect(allowRequestOrigin(dotCom, [dotCom, dotOrg, '*'])).toEqual(true); expect(allowRequestOrigin(dotCom, [dotCom, dotOrg, '*'])).toEqual(true);
}); });
test('corsOriginMiddleware origin validation', async () => {
const service = createSettingService([]);
const userName = randomId();
await expect(() =>
service.setFrontendSettings({ frontendApiOrigins: ['a'] }, userName),
).rejects.toThrow('Invalid origin: a');
});
test('corsOriginMiddleware without config', async () => {
const service = createSettingService([]);
const userName = randomId();
expect(await service.getFrontendSettings()).toEqual({
frontendApiOrigins: [],
});
await service.setFrontendSettings({ frontendApiOrigins: [] }, userName);
expect(await service.getFrontendSettings()).toEqual({
frontendApiOrigins: [],
});
await service.setFrontendSettings({ frontendApiOrigins: ['*'] }, userName);
expect(await service.getFrontendSettings()).toEqual({
frontendApiOrigins: ['*'],
});
await service.delete(frontendSettingsKey, userName);
expect(await service.getFrontendSettings()).toEqual({
frontendApiOrigins: [],
});
});
test('corsOriginMiddleware with config', async () => {
const service = createSettingService(['*']);
const userName = randomId();
expect(await service.getFrontendSettings()).toEqual({
frontendApiOrigins: ['*'],
});
await service.setFrontendSettings({ frontendApiOrigins: [] }, userName);
expect(await service.getFrontendSettings()).toEqual({
frontendApiOrigins: [],
});
await service.setFrontendSettings(
{ frontendApiOrigins: ['https://example.com', 'https://example.org'] },
userName,
);
expect(await service.getFrontendSettings()).toEqual({
frontendApiOrigins: ['https://example.com', 'https://example.org'],
});
await service.delete(frontendSettingsKey, userName);
expect(await service.getFrontendSettings()).toEqual({
frontendApiOrigins: ['*'],
});
});

View File

@ -1,25 +1,33 @@
import { RequestHandler } from 'express'; import { RequestHandler } from 'express';
import cors from 'cors'; import cors from 'cors';
import { IUnleashServices } from '../types';
const ANY_ORIGIN = '*';
export const allowRequestOrigin = ( export const allowRequestOrigin = (
requestOrigin: string, requestOrigin: string,
allowedOrigins: string[], allowedOrigins: string[],
): boolean => { ): boolean => {
return allowedOrigins.some((allowedOrigin) => { return allowedOrigins.some((allowedOrigin) => {
return allowedOrigin === requestOrigin || allowedOrigin === ANY_ORIGIN; return allowedOrigin === requestOrigin || allowedOrigin === '*';
}); });
}; };
// Check the request's Origin header against a list of allowed origins. // Check the request's Origin header against a list of allowed origins.
// The list may include '*', which `cors` does not support natively. // The list may include '*', which `cors` does not support natively.
export const corsOriginMiddleware = ( export const corsOriginMiddleware = ({
allowedOrigins: string[], settingService,
): RequestHandler => { }: Pick<IUnleashServices, 'settingService'>): RequestHandler => {
return cors((req, callback) => { return cors(async (req, callback) => {
callback(null, { try {
origin: allowRequestOrigin(req.header('Origin'), allowedOrigins), const { frontendApiOrigins = [] } =
}); await settingService.getFrontendSettings();
callback(null, {
origin: allowRequestOrigin(
req.header('Origin'),
frontendApiOrigins,
),
});
} catch (error) {
callback(error);
}
}); });
}; };

View File

@ -110,6 +110,7 @@ import { proxyFeaturesSchema } from './spec/proxy-features-schema';
import { proxyFeatureSchema } from './spec/proxy-feature-schema'; import { proxyFeatureSchema } from './spec/proxy-feature-schema';
import { proxyClientSchema } from './spec/proxy-client-schema'; import { proxyClientSchema } from './spec/proxy-client-schema';
import { proxyMetricsSchema } from './spec/proxy-metrics-schema'; import { proxyMetricsSchema } from './spec/proxy-metrics-schema';
import { setUiConfigSchema } from './spec/set-ui-config-schema';
// All schemas in `openapi/spec` should be listed here. // All schemas in `openapi/spec` should be listed here.
export const schemas = { export const schemas = {
@ -187,6 +188,7 @@ export const schemas = {
searchEventsSchema, searchEventsSchema,
segmentSchema, segmentSchema,
setStrategySortOrderSchema, setStrategySortOrderSchema,
setUiConfigSchema,
sortOrderSchema, sortOrderSchema,
splashSchema, splashSchema,
stateSchema, stateSchema,

View File

@ -0,0 +1,24 @@
import { FromSchema } from 'json-schema-to-ts';
export const setUiConfigSchema = {
$id: '#/components/schemas/setUiConfigSchema',
type: 'object',
additionalProperties: false,
properties: {
frontendSettings: {
type: 'object',
additionalProperties: false,
required: ['frontendApiOrigins'],
properties: {
frontendApiOrigins: {
type: 'array',
additionalProperties: false,
items: { type: 'string' },
},
},
},
},
components: {},
} as const;
export type SetUiConfigSchema = FromSchema<typeof setUiConfigSchema>;

View File

@ -37,6 +37,12 @@ export const uiConfigSchema = {
strategySegmentsLimit: { strategySegmentsLimit: {
type: 'number', type: 'number',
}, },
frontendApiOrigins: {
type: 'array',
items: {
type: 'string',
},
},
flags: { flags: {
type: 'object', type: 'object',
additionalProperties: { additionalProperties: {

View File

@ -1,4 +1,5 @@
import { Request, Response } from 'express'; import { Response } from 'express';
import { AuthedRequest } from '../../types/core';
import { IUnleashServices } from '../../types/services'; import { IUnleashServices } from '../../types/services';
import { IAuthType, IUnleashConfig } from '../../types/option'; import { IAuthType, IUnleashConfig } from '../../types/option';
import version from '../../util/version'; import version from '../../util/version';
@ -6,10 +7,10 @@ import Controller from '../controller';
import VersionService from '../../services/version-service'; import VersionService from '../../services/version-service';
import SettingService from '../../services/setting-service'; import SettingService from '../../services/setting-service';
import { import {
simpleAuthKey, simpleAuthSettingsKey,
SimpleAuthSettings, SimpleAuthSettings,
} from '../../types/settings/simple-auth-settings'; } from '../../types/settings/simple-auth-settings';
import { NONE } from '../../types/permissions'; import { ADMIN, NONE } from '../../types/permissions';
import { createResponseSchema } from '../../openapi/util/create-response-schema'; import { createResponseSchema } from '../../openapi/util/create-response-schema';
import { import {
uiConfigSchema, uiConfigSchema,
@ -17,6 +18,12 @@ import {
} from '../../openapi/spec/ui-config-schema'; } from '../../openapi/spec/ui-config-schema';
import { OpenApiService } from '../../services/openapi-service'; import { OpenApiService } from '../../services/openapi-service';
import { EmailService } from '../../services/email-service'; import { EmailService } from '../../services/email-service';
import { emptyResponse } from '../../openapi/util/standard-responses';
import { IAuthRequest } from '../unleash-types';
import { extractUsername } from '../../util/extract-user';
import NotFoundError from '../../error/notfound-error';
import { SetUiConfigSchema } from '../../openapi/spec/set-ui-config-schema';
import { createRequestSchema } from '../../openapi/util/create-request-schema';
class ConfigController extends Controller { class ConfigController extends Controller {
private versionService: VersionService; private versionService: VersionService;
@ -51,33 +58,56 @@ class ConfigController extends Controller {
this.route({ this.route({
method: 'get', method: 'get',
path: '', path: '',
handler: this.getUIConfig, handler: this.getUiConfig,
permission: NONE, permission: NONE,
middleware: [ middleware: [
openApiService.validPath({ openApiService.validPath({
tags: ['Admin UI'], tags: ['Admin UI'],
operationId: 'getUIConfig', operationId: 'getUiConfig',
responses: { responses: {
200: createResponseSchema('uiConfigSchema'), 200: createResponseSchema('uiConfigSchema'),
}, },
}), }),
], ],
}); });
this.route({
method: 'post',
path: '',
handler: this.setUiConfig,
permission: ADMIN,
middleware: [
openApiService.validPath({
tags: ['Admin UI'],
operationId: 'setUiConfig',
requestBody: createRequestSchema('setUiConfigSchema'),
responses: { 200: emptyResponse },
}),
],
});
} }
async getUIConfig( async getUiConfig(
req: Request, req: AuthedRequest,
res: Response<UiConfigSchema>, res: Response<UiConfigSchema>,
): Promise<void> { ): Promise<void> {
const simpleAuthSettings = const [frontendSettings, simpleAuthSettings] = await Promise.all([
await this.settingService.get<SimpleAuthSettings>(simpleAuthKey); this.settingService.getFrontendSettings(),
this.settingService.get<SimpleAuthSettings>(simpleAuthSettingsKey),
]);
const disablePasswordAuth = const disablePasswordAuth =
simpleAuthSettings?.disabled || simpleAuthSettings?.disabled ||
this.config.authentication.type == IAuthType.NONE; this.config.authentication.type == IAuthType.NONE;
const expFlags = this.config.flagResolver.getAll({
email: req.user.email,
});
const flags = { ...this.config.ui.flags, ...expFlags };
const response: UiConfigSchema = { const response: UiConfigSchema = {
...this.config.ui, ...this.config.ui,
flags,
version, version,
emailEnabled: this.emailService.isEnabled(), emailEnabled: this.emailService.isEnabled(),
unleashUrl: this.config.server.unleashUrl, unleashUrl: this.config.server.unleashUrl,
@ -85,9 +115,10 @@ class ConfigController extends Controller {
authenticationType: this.config.authentication?.type, authenticationType: this.config.authentication?.type,
segmentValuesLimit: this.config.segmentValuesLimit, segmentValuesLimit: this.config.segmentValuesLimit,
strategySegmentsLimit: this.config.strategySegmentsLimit, strategySegmentsLimit: this.config.strategySegmentsLimit,
frontendApiOrigins: frontendSettings.frontendApiOrigins,
versionInfo: this.versionService.getVersionInfo(), versionInfo: this.versionService.getVersionInfo(),
disablePasswordAuth, disablePasswordAuth,
embedProxy: this.config.experimental.embedProxy, embedProxy: this.config.experimental.flags.embedProxy,
}; };
this.openApiService.respondWithValidation( this.openApiService.respondWithValidation(
@ -97,5 +128,22 @@ class ConfigController extends Controller {
response, response,
); );
} }
async setUiConfig(
req: IAuthRequest<void, void, SetUiConfigSchema>,
res: Response<string>,
): Promise<void> {
if (req.body.frontendSettings) {
await this.settingService.setFrontendSettings(
req.body.frontendSettings,
extractUsername(req),
);
res.sendStatus(204);
return;
}
throw new NotFoundError();
}
} }
export default ConfigController; export default ConfigController;

View File

@ -21,12 +21,13 @@ import {
import { getStandardResponses } from '../../../lib/openapi/util/standard-responses'; import { getStandardResponses } from '../../../lib/openapi/util/standard-responses';
import { createRequestSchema } from '../../openapi/util/create-request-schema'; import { createRequestSchema } from '../../openapi/util/create-request-schema';
import { SearchEventsSchema } from '../../openapi/spec/search-events-schema'; import { SearchEventsSchema } from '../../openapi/spec/search-events-schema';
import { IFlagResolver } from '../../types/experimental';
const version = 1; const version = 1;
export default class EventController extends Controller { export default class EventController extends Controller {
private eventService: EventService; private eventService: EventService;
private anonymise: boolean = false; private flagResolver: IFlagResolver;
private openApiService: OpenApiService; private openApiService: OpenApiService;
@ -39,7 +40,7 @@ export default class EventController extends Controller {
) { ) {
super(config); super(config);
this.eventService = eventService; this.eventService = eventService;
this.anonymise = config.experimental?.anonymiseEventLog; this.flagResolver = config.flagResolver;
this.openApiService = openApiService; this.openApiService = openApiService;
this.route({ this.route({
@ -106,7 +107,7 @@ export default class EventController extends Controller {
} }
maybeAnonymiseEvents(events: IEvent[]): IEvent[] { maybeAnonymiseEvents(events: IEvent[]): IEvent[] {
if (this.anonymise) { if (this.flagResolver.isEnabled('anonymiseEventLog')) {
return events.map((e: IEvent) => ({ return events.map((e: IEvent) => ({
...e, ...e,
createdBy: anonymise(e.createdBy), createdBy: anonymise(e.createdBy),

View File

@ -12,7 +12,7 @@ async function getSetup(anonymise: boolean = false) {
const stores = createStores(); const stores = createStores();
const config = createTestConfig({ const config = createTestConfig({
server: { baseUriPath: base }, server: { baseUriPath: base },
experimental: { anonymiseEventLog: anonymise }, experimental: { flags: { anonymiseEventLog: anonymise } },
}); });
const services = createServices(stores, config); const services = createServices(stores, config);
const app = await getApp(config, stores, services); const app = await getApp(config, stores, services);

View File

@ -10,7 +10,7 @@ import ResetTokenService from '../../services/reset-token-service';
import { IAuthRequest } from '../unleash-types'; import { IAuthRequest } from '../unleash-types';
import SettingService from '../../services/setting-service'; import SettingService from '../../services/setting-service';
import { IUser, SimpleAuthSettings } from '../../server-impl'; import { IUser, SimpleAuthSettings } from '../../server-impl';
import { simpleAuthKey } from '../../types/settings/simple-auth-settings'; import { simpleAuthSettingsKey } from '../../types/settings/simple-auth-settings';
import { anonymise } from '../../util/anonymise'; import { anonymise } from '../../util/anonymise';
import { OpenApiService } from '../../services/openapi-service'; import { OpenApiService } from '../../services/openapi-service';
import { createRequestSchema } from '../../openapi/util/create-request-schema'; import { createRequestSchema } from '../../openapi/util/create-request-schema';
@ -37,9 +37,10 @@ import {
usersGroupsBaseSchema, usersGroupsBaseSchema,
} from '../../openapi/spec/users-groups-base-schema'; } from '../../openapi/spec/users-groups-base-schema';
import { IGroup } from '../../types/group'; import { IGroup } from '../../types/group';
import { IFlagResolver } from '../../types/experimental';
export default class UserAdminController extends Controller { export default class UserAdminController extends Controller {
private anonymise: boolean = false; private flagResolver: IFlagResolver;
private userService: UserService; private userService: UserService;
@ -90,7 +91,7 @@ export default class UserAdminController extends Controller {
this.groupService = groupService; this.groupService = groupService;
this.logger = config.getLogger('routes/user-controller.ts'); this.logger = config.getLogger('routes/user-controller.ts');
this.unleashUrl = config.server.unleashUrl; this.unleashUrl = config.server.unleashUrl;
this.anonymise = config.experimental?.anonymiseEventLog; this.flagResolver = config.flagResolver;
this.route({ this.route({
method: 'post', method: 'post',
@ -294,7 +295,7 @@ export default class UserAdminController extends Controller {
typeof q === 'string' && q.length > 1 typeof q === 'string' && q.length > 1
? await this.userService.search(q) ? await this.userService.search(q)
: []; : [];
if (this.anonymise) { if (this.flagResolver.isEnabled('anonymiseEventLog')) {
users = this.anonymiseUsers(users); users = this.anonymiseUsers(users);
} }
this.openApiService.respondWithValidation( this.openApiService.respondWithValidation(
@ -368,7 +369,9 @@ export default class UserAdminController extends Controller {
); );
const passwordAuthSettings = const passwordAuthSettings =
await this.settingService.get<SimpleAuthSettings>(simpleAuthKey); await this.settingService.get<SimpleAuthSettings>(
simpleAuthSettingsKey,
);
let inviteLink: string; let inviteLink: string;
if (!passwordAuthSettings?.disabled) { if (!passwordAuthSettings?.disabled) {

View File

@ -82,9 +82,7 @@ test('should accept client metrics with yes/no', () => {
}); });
test('should accept client metrics with yes/no with metricsV2', async () => { test('should accept client metrics with yes/no with metricsV2', async () => {
const testRunner = await getSetup({ const testRunner = await getSetup();
experimental: { metricsV2: { enabled: true } },
});
await testRunner.request await testRunner.request
.post('/api/client/metrics') .post('/api/client/metrics')
.send({ .send({

View File

@ -28,7 +28,7 @@ class IndexRouter extends Controller {
this.use('/api/admin', new AdminApi(config, services).router); this.use('/api/admin', new AdminApi(config, services).router);
this.use('/api/client', new ClientApi(config, services).router); this.use('/api/client', new ClientApi(config, services).router);
if (config.experimental.embedProxy) { if (config.experimental.flags.embedProxy) {
this.use( this.use(
'/api/frontend', '/api/frontend',
new ProxyController(config, services).router, new ProxyController(config, services).router,

View File

@ -2,9 +2,7 @@ import { Response, Request } from 'express';
import Controller from '../controller'; import Controller from '../controller';
import { IUnleashConfig, IUnleashServices } from '../../types'; import { IUnleashConfig, IUnleashServices } from '../../types';
import { Logger } from '../../logger'; import { Logger } from '../../logger';
import { OpenApiService } from '../../services/openapi-service';
import { NONE } from '../../types/permissions'; import { NONE } from '../../types/permissions';
import { ProxyService } from '../../services/proxy-service';
import ApiUser from '../../types/api-user'; import ApiUser from '../../types/api-user';
import { import {
proxyFeaturesSchema, proxyFeaturesSchema,
@ -28,29 +26,25 @@ interface ApiUserRequest<
user: ApiUser; user: ApiUser;
} }
type Services = Pick<
IUnleashServices,
'settingService' | 'proxyService' | 'openApiService'
>;
export default class ProxyController extends Controller { export default class ProxyController extends Controller {
private readonly logger: Logger; private readonly logger: Logger;
private proxyService: ProxyService; private services: Services;
private openApiService: OpenApiService; constructor(config: IUnleashConfig, services: Services) {
constructor(
config: IUnleashConfig,
{
proxyService,
openApiService,
}: Pick<IUnleashServices, 'proxyService' | 'openApiService'>,
) {
super(config); super(config);
this.logger = config.getLogger('client-api/feature.js'); this.logger = config.getLogger('client-api/feature.js');
this.proxyService = proxyService; this.services = services;
this.openApiService = openApiService;
if (config.frontendApiOrigins.length > 0) { if (config.frontendApiOrigins.length > 0) {
// Support CORS requests for the frontend endpoints. // Support CORS requests for the frontend endpoints.
// Preflight requests are handled in `app.ts`. // Preflight requests are handled in `app.ts`.
this.app.use(corsOriginMiddleware(config.frontendApiOrigins)); this.app.use(corsOriginMiddleware(services));
} }
this.route({ this.route({
@ -59,7 +53,7 @@ export default class ProxyController extends Controller {
handler: this.getProxyFeatures, handler: this.getProxyFeatures,
permission: NONE, permission: NONE,
middleware: [ middleware: [
this.openApiService.validPath({ this.services.openApiService.validPath({
tags: ['Unstable'], tags: ['Unstable'],
operationId: 'getFrontendFeatures', operationId: 'getFrontendFeatures',
responses: { responses: {
@ -89,7 +83,7 @@ export default class ProxyController extends Controller {
handler: this.registerProxyMetrics, handler: this.registerProxyMetrics,
permission: NONE, permission: NONE,
middleware: [ middleware: [
this.openApiService.validPath({ this.services.openApiService.validPath({
tags: ['Unstable'], tags: ['Unstable'],
operationId: 'registerFrontendMetrics', operationId: 'registerFrontendMetrics',
requestBody: createRequestSchema('proxyMetricsSchema'), requestBody: createRequestSchema('proxyMetricsSchema'),
@ -104,7 +98,7 @@ export default class ProxyController extends Controller {
handler: ProxyController.registerProxyClient, handler: ProxyController.registerProxyClient,
permission: NONE, permission: NONE,
middleware: [ middleware: [
this.openApiService.validPath({ this.services.openApiService.validPath({
tags: ['Unstable'], tags: ['Unstable'],
operationId: 'registerFrontendClient', operationId: 'registerFrontendClient',
requestBody: createRequestSchema('proxyClientSchema'), requestBody: createRequestSchema('proxyClientSchema'),
@ -141,11 +135,11 @@ export default class ProxyController extends Controller {
req: ApiUserRequest, req: ApiUserRequest,
res: Response<ProxyFeaturesSchema>, res: Response<ProxyFeaturesSchema>,
) { ) {
const toggles = await this.proxyService.getProxyFeatures( const toggles = await this.services.proxyService.getProxyFeatures(
req.user, req.user,
ProxyController.createContext(req), ProxyController.createContext(req),
); );
this.openApiService.respondWithValidation( this.services.openApiService.respondWithValidation(
200, 200,
res, res,
proxyFeaturesSchema.$id, proxyFeaturesSchema.$id,
@ -157,7 +151,7 @@ export default class ProxyController extends Controller {
req: ApiUserRequest<unknown, unknown, ProxyMetricsSchema>, req: ApiUserRequest<unknown, unknown, ProxyMetricsSchema>,
res: Response, res: Response,
) { ) {
await this.proxyService.registerProxyMetrics( await this.services.proxyService.registerProxyMetrics(
req.user, req.user,
req.body, req.body,
req.ip, req.ip,

View File

@ -16,7 +16,6 @@ import ApiUser from '../../types/api-user';
import { ALL } from '../../types/models/api-token'; import { ALL } from '../../types/models/api-token';
import User from '../../types/user'; import User from '../../types/user';
import { collapseHourlyMetrics } from '../../util/collapseHourlyMetrics'; import { collapseHourlyMetrics } from '../../util/collapseHourlyMetrics';
import { IExperimentalOptions } from '../../experimental';
export default class ClientMetricsServiceV2 { export default class ClientMetricsServiceV2 {
private timers: NodeJS.Timeout[] = []; private timers: NodeJS.Timeout[] = [];
@ -27,7 +26,7 @@ export default class ClientMetricsServiceV2 {
private featureToggleStore: IFeatureToggleStore; private featureToggleStore: IFeatureToggleStore;
private experimental: IExperimentalOptions; private batchMetricsEnabled: boolean;
private eventBus: EventEmitter; private eventBus: EventEmitter;
@ -47,13 +46,13 @@ export default class ClientMetricsServiceV2 {
) { ) {
this.featureToggleStore = featureToggleStore; this.featureToggleStore = featureToggleStore;
this.clientMetricsStoreV2 = clientMetricsStoreV2; this.clientMetricsStoreV2 = clientMetricsStoreV2;
this.experimental = experimental; this.batchMetricsEnabled = experimental.flags.batchMetrics;
this.eventBus = eventBus; this.eventBus = eventBus;
this.logger = getLogger( this.logger = getLogger(
'/services/client-metrics/client-metrics-service-v2.ts', '/services/client-metrics/client-metrics-service-v2.ts',
); );
if (this.experimental.batchMetrics) { if (this.batchMetricsEnabled) {
this.timers.push( this.timers.push(
setInterval(() => { setInterval(() => {
this.bulkAdd().catch(console.error); this.bulkAdd().catch(console.error);
@ -91,7 +90,7 @@ export default class ClientMetricsServiceV2 {
})) }))
.filter((item) => !(item.yes === 0 && item.no === 0)); .filter((item) => !(item.yes === 0 && item.no === 0));
if (this.experimental.batchMetrics) { if (this.batchMetricsEnabled) {
this.unsavedMetrics = collapseHourlyMetrics([ this.unsavedMetrics = collapseHourlyMetrics([
...this.unsavedMetrics, ...this.unsavedMetrics,
...clientMetrics, ...clientMetrics,
@ -104,7 +103,7 @@ export default class ClientMetricsServiceV2 {
} }
async bulkAdd(): Promise<void> { async bulkAdd(): Promise<void> {
if (this.experimental.batchMetrics && this.unsavedMetrics.length > 0) { if (this.batchMetricsEnabled && this.unsavedMetrics.length > 0) {
// Make a copy of `unsavedMetrics` in case new metrics // Make a copy of `unsavedMetrics` in case new metrics
// arrive while awaiting `batchInsertMetrics`. // arrive while awaiting `batchInsertMetrics`.
const copy = [...this.unsavedMetrics]; const copy = [...this.unsavedMetrics];

View File

@ -294,12 +294,11 @@ export default class ProjectService {
}; };
} }
// TODO: Remove the optional nature of createdBy - this in place to make sure enterprise is compatible
async addUser( async addUser(
projectId: string, projectId: string,
roleId: number, roleId: number,
userId: number, userId: number,
createdBy?: string, createdBy: string,
): Promise<void> { ): Promise<void> {
const [roles, users] = await this.accessService.getProjectRoleAccess( const [roles, users] = await this.accessService.getProjectRoleAccess(
projectId, projectId,
@ -323,7 +322,7 @@ export default class ProjectService {
await this.eventStore.store( await this.eventStore.store(
new ProjectUserAddedEvent({ new ProjectUserAddedEvent({
project: projectId, project: projectId,
createdBy, createdBy: createdBy || 'system-user',
data: { data: {
roleId, roleId,
userId, userId,
@ -334,12 +333,11 @@ export default class ProjectService {
); );
} }
// TODO: Remove the optional nature of createdBy - this in place to make sure enterprise is compatible
async removeUser( async removeUser(
projectId: string, projectId: string,
roleId: number, roleId: number,
userId: number, userId: number,
createdBy?: string, createdBy: string,
): Promise<void> { ): Promise<void> {
const role = await this.findProjectRole(projectId, roleId); const role = await this.findProjectRole(projectId, roleId);
@ -367,7 +365,7 @@ export default class ProjectService {
projectId: string, projectId: string,
roleId: number, roleId: number,
groupId: number, groupId: number,
modifiedBy?: string, modifiedBy: string,
): Promise<void> { ): Promise<void> {
const role = await this.accessService.getRole(roleId); const role = await this.accessService.getRole(roleId);
const group = await this.groupService.getGroup(groupId); const group = await this.groupService.getGroup(groupId);
@ -397,7 +395,7 @@ export default class ProjectService {
projectId: string, projectId: string,
roleId: number, roleId: number,
groupId: number, groupId: number,
modifiedBy?: string, modifiedBy: string,
): Promise<void> { ): Promise<void> {
const group = await this.groupService.getGroup(groupId); const group = await this.groupService.getGroup(groupId);
const role = await this.accessService.getRole(roleId); const role = await this.accessService.getRole(roleId);

View File

@ -8,31 +8,48 @@ import {
SettingDeletedEvent, SettingDeletedEvent,
SettingUpdatedEvent, SettingUpdatedEvent,
} from '../types/events'; } from '../types/events';
import { validateOrigins } from '../util/validateOrigin';
import {
FrontendSettings,
frontendSettingsKey,
} from '../types/settings/frontend-settings';
import BadDataError from '../error/bad-data-error';
export default class SettingService { export default class SettingService {
private config: IUnleashConfig;
private logger: Logger; private logger: Logger;
private settingStore: ISettingStore; private settingStore: ISettingStore;
private eventStore: IEventStore; private eventStore: IEventStore;
// SettingService.getFrontendSettings is called on every request to the
// frontend API. Keep fetched settings in a cache for fewer DB queries.
private cache = new Map<string, unknown>();
constructor( constructor(
{ {
settingStore, settingStore,
eventStore, eventStore,
}: Pick<IUnleashStores, 'settingStore' | 'eventStore'>, }: Pick<IUnleashStores, 'settingStore' | 'eventStore'>,
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>, config: IUnleashConfig,
) { ) {
this.logger = getLogger('services/setting-service.ts'); this.config = config;
this.logger = config.getLogger('services/setting-service.ts');
this.settingStore = settingStore; this.settingStore = settingStore;
this.eventStore = eventStore; this.eventStore = eventStore;
} }
async get<T>(id: string): Promise<T> { async get<T>(id: string, defaultValue?: T): Promise<T> {
return this.settingStore.get(id); if (!this.cache.has(id)) {
this.cache.set(id, await this.settingStore.get(id));
}
return (this.cache.get(id) as T) || defaultValue;
} }
async insert(id: string, value: object, createdBy: string): Promise<void> { async insert(id: string, value: object, createdBy: string): Promise<void> {
this.cache.delete(id);
const exists = await this.settingStore.exists(id); const exists = await this.settingStore.exists(id);
if (exists) { if (exists) {
await this.settingStore.updateRow(id, value); await this.settingStore.updateRow(id, value);
@ -54,6 +71,7 @@ export default class SettingService {
} }
async delete(id: string, createdBy: string): Promise<void> { async delete(id: string, createdBy: string): Promise<void> {
this.cache.delete(id);
await this.settingStore.delete(id); await this.settingStore.delete(id);
await this.eventStore.store( await this.eventStore.store(
new SettingDeletedEvent({ new SettingDeletedEvent({
@ -64,6 +82,28 @@ export default class SettingService {
}), }),
); );
} }
async deleteAll(): Promise<void> {
this.cache.clear();
await this.settingStore.deleteAll();
}
async setFrontendSettings(
value: FrontendSettings,
createdBy: string,
): Promise<void> {
const error = validateOrigins(value.frontendApiOrigins);
if (error) {
throw new BadDataError(error);
}
await this.insert(frontendSettingsKey, value, createdBy);
}
async getFrontendSettings(): Promise<FrontendSettings> {
return this.get(frontendSettingsKey, {
frontendApiOrigins: this.config.frontendApiOrigins,
});
}
} }
module.exports = SettingService; module.exports = SettingService;

View File

@ -22,7 +22,7 @@ import { IUserStore } from '../types/stores/user-store';
import { RoleName } from '../types/model'; import { RoleName } from '../types/model';
import SettingService from './setting-service'; import SettingService from './setting-service';
import { SimpleAuthSettings } from '../server-impl'; import { SimpleAuthSettings } from '../server-impl';
import { simpleAuthKey } from '../types/settings/simple-auth-settings'; import { simpleAuthSettingsKey } from '../types/settings/simple-auth-settings';
import DisabledError from '../error/disabled-error'; import DisabledError from '../error/disabled-error';
import PasswordMismatch from '../error/password-mismatch'; import PasswordMismatch from '../error/password-mismatch';
import BadDataError from '../error/bad-data-error'; import BadDataError from '../error/bad-data-error';
@ -276,7 +276,7 @@ class UserService {
async loginUser(usernameOrEmail: string, password: string): Promise<IUser> { async loginUser(usernameOrEmail: string, password: string): Promise<IUser> {
const settings = await this.settingService.get<SimpleAuthSettings>( const settings = await this.settingService.get<SimpleAuthSettings>(
simpleAuthKey, simpleAuthSettingsKey,
); );
if (settings?.disabled) { if (settings?.disabled) {

View File

@ -0,0 +1,43 @@
import { parseEnvVarBoolean } from '../util/parseEnvVar';
export type IFlags = Partial<Record<string, boolean>>;
export const defaultExperimentalOptions = {
flags: {
ENABLE_DARK_MODE_SUPPORT: false,
anonymiseEventLog: false,
embedProxy: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_EMBED_PROXY,
false,
),
batchMetrics: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_BATCH_METRICS,
false,
),
},
externalResolver: { isEnabled: (): boolean => false },
};
export interface IExperimentalOptions {
flags: {
[key: string]: boolean;
ENABLE_DARK_MODE_SUPPORT?: boolean;
embedProxy?: boolean;
batchMetrics?: boolean;
anonymiseEventLog?: boolean;
};
externalResolver: IExternalFlagResolver;
}
export interface IFlagContext {
[key: string]: string;
}
export interface IFlagResolver {
getAll: (context?: IFlagContext) => IFlags;
isEnabled: (expName: string, context?: IFlagContext) => boolean;
}
export interface IExternalFlagResolver {
isEnabled: (flagName: string, context?: IFlagContext) => boolean;
}

View File

@ -1,7 +1,7 @@
import EventEmitter from 'events'; import EventEmitter from 'events';
import { LogLevel, LogProvider } from '../logger'; import { LogLevel, LogProvider } from '../logger';
import { ILegacyApiTokenCreate } from './models/api-token'; import { ILegacyApiTokenCreate } from './models/api-token';
import { IExperimentalOptions } from '../experimental'; import { IFlagResolver, IExperimentalOptions, IFlags } from './experimental';
import SMTPTransport from 'nodemailer/lib/smtp-transport'; import SMTPTransport from 'nodemailer/lib/smtp-transport';
export type EventHook = (eventName: string, data: object) => void; export type EventHook = (eventName: string, data: object) => void;
@ -102,7 +102,7 @@ export interface IUnleashOptions {
authentication?: Partial<IAuthOption>; authentication?: Partial<IAuthOption>;
ui?: object; ui?: object;
import?: Partial<IImportOption>; import?: Partial<IImportOption>;
experimental?: IExperimentalOptions; experimental?: Partial<IExperimentalOptions>;
email?: Partial<IEmailOption>; email?: Partial<IEmailOption>;
secureHeaders?: boolean; secureHeaders?: boolean;
additionalCspAllowedDomains?: ICspDomainOptions; additionalCspAllowedDomains?: ICspDomainOptions;
@ -139,7 +139,6 @@ export interface IListeningHost {
export interface IUIConfig { export interface IUIConfig {
slogan?: string; slogan?: string;
name?: string; name?: string;
flags?: { [key: string]: boolean };
links?: [ links?: [
{ {
value: string; value: string;
@ -148,6 +147,7 @@ export interface IUIConfig {
title: string; title: string;
}, },
]; ];
flags?: IFlags;
} }
export interface ICspDomainOptions { export interface ICspDomainOptions {
@ -177,6 +177,7 @@ export interface IUnleashConfig {
ui: IUIConfig; ui: IUIConfig;
import: IImportOption; import: IImportOption;
experimental?: IExperimentalOptions; experimental?: IExperimentalOptions;
flagResolver: IFlagResolver;
email: IEmailOption; email: IEmailOption;
secureHeaders: boolean; secureHeaders: boolean;
additionalCspAllowedDomains: ICspDomainConfig; additionalCspAllowedDomains: ICspDomainConfig;

View File

@ -0,0 +1,5 @@
import { IUnleashConfig } from '../option';
export const frontendSettingsKey = 'unleash.frontend';
export type FrontendSettings = Pick<IUnleashConfig, 'frontendApiOrigins'>;

View File

@ -1,4 +1,5 @@
export const simpleAuthKey = 'unleash.auth.simple'; export const simpleAuthSettingsKey = 'unleash.auth.simple';
export interface SimpleAuthSettings { export interface SimpleAuthSettings {
disabled: boolean; disabled: boolean;
} }

View File

@ -0,0 +1,101 @@
import { defaultExperimentalOptions } from '../types/experimental';
import FlagResolver from './flag-resolver';
test('should produce empty exposed flags', () => {
const resolver = new FlagResolver(defaultExperimentalOptions);
const result = resolver.getAll();
expect(result.ENABLE_DARK_MODE_SUPPORT).toBe(false);
});
test('should produce UI flags with extra dynamic flags', () => {
const config = {
...defaultExperimentalOptions,
flags: { extraFlag: false },
};
const resolver = new FlagResolver(config);
const result = resolver.getAll();
expect(result.extraFlag).toBe(false);
});
test('should use external resolver for dynamic flags', () => {
const externalResolver = {
isEnabled: (name: string) => {
if (name === 'extraFlag') {
return true;
}
},
};
const resolver = new FlagResolver({
flags: {
extraFlag: false,
},
externalResolver,
});
const result = resolver.getAll();
expect(result.extraFlag).toBe(true);
});
test('should not use external resolver for enabled experiments', () => {
const externalResolver = {
isEnabled: () => {
return false;
},
};
const resolver = new FlagResolver({
flags: {
should_be_enabled: true,
extraFlag: false,
},
externalResolver,
});
const result = resolver.getAll();
expect(result.should_be_enabled).toBe(true);
});
test('should load experimental flags', () => {
const externalResolver = {
isEnabled: () => {
return false;
},
};
const resolver = new FlagResolver({
flags: {
extraFlag: false,
someFlag: true,
},
externalResolver,
});
expect(resolver.isEnabled('someFlag')).toBe(true);
expect(resolver.isEnabled('extraFlag')).toBe(false);
});
test('should load experimental flags from external provider', () => {
const externalResolver = {
isEnabled: (name: string) => {
if (name === 'extraFlag') {
return true;
}
},
};
const resolver = new FlagResolver({
flags: {
extraFlag: false,
someFlag: true,
},
externalResolver,
});
expect(resolver.isEnabled('someFlag')).toBe(true);
expect(resolver.isEnabled('extraFlag')).toBe(true);
});

View File

@ -0,0 +1,38 @@
import {
IExperimentalOptions,
IExternalFlagResolver,
IFlagContext,
IFlags,
IFlagResolver,
} from '../types/experimental';
export default class FlagResolver implements IFlagResolver {
private experiments: IFlags;
private externalResolver: IExternalFlagResolver;
constructor(expOpt: IExperimentalOptions) {
this.experiments = expOpt.flags;
this.externalResolver = expOpt.externalResolver;
}
getAll(context?: IFlagContext): IFlags {
const flags: IFlags = { ...this.experiments };
Object.keys(flags).forEach((flagName) => {
if (!this.experiments[flagName])
flags[flagName] = this.externalResolver.isEnabled(
flagName,
context,
);
});
return flags;
}
isEnabled(expName: string, context?: IFlagContext): boolean {
if (this.experiments[expName]) {
return true;
}
return this.externalResolver.isEnabled(expName, context);
}
}

View File

@ -29,9 +29,11 @@ test('parseEnvVarBoolean', () => {
}); });
test('parseEnvVarStringList', () => { test('parseEnvVarStringList', () => {
expect(parseEnvVarStrings(undefined, [])).toEqual([]);
expect(parseEnvVarStrings(undefined, ['a'])).toEqual(['a']);
expect(parseEnvVarStrings('', ['a'])).toEqual([]);
expect(parseEnvVarStrings('', [])).toEqual([]); expect(parseEnvVarStrings('', [])).toEqual([]);
expect(parseEnvVarStrings(' ', [])).toEqual([]); expect(parseEnvVarStrings(' ', [])).toEqual([]);
expect(parseEnvVarStrings('', ['*'])).toEqual(['*']);
expect(parseEnvVarStrings('a', ['*'])).toEqual(['a']); expect(parseEnvVarStrings('a', ['*'])).toEqual(['a']);
expect(parseEnvVarStrings('a,b,c', [])).toEqual(['a', 'b', 'c']); expect(parseEnvVarStrings('a,b,c', [])).toEqual(['a', 'b', 'c']);
expect(parseEnvVarStrings('a,b,c', [])).toEqual(['a', 'b', 'c']); expect(parseEnvVarStrings('a,b,c', [])).toEqual(['a', 'b', 'c']);

View File

@ -20,10 +20,10 @@ export function parseEnvVarBoolean(
} }
export function parseEnvVarStrings( export function parseEnvVarStrings(
envVar: string, envVar: string | undefined,
defaultVal: string[], defaultVal: string[],
): string[] { ): string[] {
if (envVar) { if (typeof envVar === 'string') {
return envVar return envVar
.split(',') .split(',')
.map((item) => item.trim()) .map((item) => item.trim())

View File

@ -0,0 +1,24 @@
import { validateOrigin } from './validateOrigin';
test('validateOrigin', () => {
expect(validateOrigin(undefined)).toEqual(false);
expect(validateOrigin('')).toEqual(false);
expect(validateOrigin(' ')).toEqual(false);
expect(validateOrigin('a')).toEqual(false);
expect(validateOrigin('**')).toEqual(false);
expect(validateOrigin('localhost')).toEqual(false);
expect(validateOrigin('localhost:8080')).toEqual(false);
expect(validateOrigin('//localhost:8080')).toEqual(false);
expect(validateOrigin('http://localhost/')).toEqual(false);
expect(validateOrigin('http://localhost/a')).toEqual(false);
expect(validateOrigin('https://example.com/a')).toEqual(false);
expect(validateOrigin('https://example.com ')).toEqual(false);
expect(validateOrigin('https://*.example.com ')).toEqual(false);
expect(validateOrigin('*.example.com')).toEqual(false);
expect(validateOrigin('*')).toEqual(true);
expect(validateOrigin('http://localhost')).toEqual(true);
expect(validateOrigin('http://localhost:8080')).toEqual(true);
expect(validateOrigin('https://example.com')).toEqual(true);
expect(validateOrigin('https://example.com:8080')).toEqual(true);
});

View File

@ -0,0 +1,24 @@
export const validateOrigin = (origin: string): boolean => {
if (origin === '*') {
return true;
}
if (origin?.includes('*')) {
return false;
}
try {
const parsed = new URL(origin);
return parsed.origin && parsed.origin === origin;
} catch {
return false;
}
};
export const validateOrigins = (origins: string[]): string | undefined => {
for (const origin of origins) {
if (!validateOrigin(origin)) {
return `Invalid origin: ${origin}`;
}
}
};

View File

@ -31,11 +31,12 @@ process.nextTick(async () => {
enable: false, enable: false,
}, },
experimental: { experimental: {
metricsV2: { enabled: true }, // externalResolver: unleash,
anonymiseEventLog: false, flags: {
userGroups: true, embedProxy: true,
embedProxy: true, batchMetrics: true,
batchMetrics: true, anonymiseEventLog: false,
},
}, },
authentication: { authentication: {
initApiTokens: [ initApiTokens: [

View File

@ -23,9 +23,10 @@ export function createTestConfig(config?: IUnleashOptions): IUnleashConfig {
enabled: false, enabled: false,
}, },
experimental: { experimental: {
userGroups: true, flags: {
embedProxy: true, embedProxy: true,
batchMetrics: true, batchMetrics: true,
},
}, },
}; };
const options = mergeAll<IUnleashOptions>([testConfig, config]); const options = mergeAll<IUnleashOptions>([testConfig, config]);

View File

@ -9,9 +9,7 @@ let db: ITestDb;
beforeAll(async () => { beforeAll(async () => {
db = await dbInit('client_metrics_serial', getLogger); db = await dbInit('client_metrics_serial', getLogger);
app = await setupAppWithCustomConfig(db.stores, { app = await setupAppWithCustomConfig(db.stores, {});
experimental: { metricsV2: { enabled: true } },
});
}); });
afterAll(async () => { afterAll(async () => {

View File

@ -1,10 +1,11 @@
import dbInit, { ITestDb } from '../../helpers/database-init'; import dbInit, { ITestDb } from '../../helpers/database-init';
import { setupApp } from '../../helpers/test-helper'; import { IUnleashTest, setupApp } from '../../helpers/test-helper';
import getLogger from '../../../fixtures/no-logger'; import getLogger from '../../../fixtures/no-logger';
import { simpleAuthKey } from '../../../../lib/types/settings/simple-auth-settings'; import { simpleAuthSettingsKey } from '../../../../lib/types/settings/simple-auth-settings';
import { randomId } from '../../../../lib/util/random-id';
let db: ITestDb; let db: ITestDb;
let app; let app: IUnleashTest;
beforeAll(async () => { beforeAll(async () => {
db = await dbInit('config_api_serial', getLogger); db = await dbInit('config_api_serial', getLogger);
@ -16,24 +17,71 @@ afterAll(async () => {
await db.destroy(); await db.destroy();
}); });
beforeEach(async () => {
await app.services.settingService.deleteAll();
});
test('gets ui config fields', async () => { test('gets ui config fields', async () => {
const { body } = await app.request const { body } = await app.request
.get('/api/admin/ui-config') .get('/api/admin/ui-config')
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200); .expect(200);
expect(body.unleashUrl).toBe('http://localhost:4242'); expect(body.unleashUrl).toBe('http://localhost:4242');
expect(body.version).toBeDefined(); expect(body.version).toBeDefined();
expect(body.emailEnabled).toBe(false); expect(body.emailEnabled).toBe(false);
}); });
test('gets ui config with disablePasswordAuth', async () => { test('gets ui config with disablePasswordAuth', async () => {
await db.stores.settingStore.insert(simpleAuthKey, { disabled: true }); await db.stores.settingStore.insert(simpleAuthSettingsKey, {
disabled: true,
});
const { body } = await app.request const { body } = await app.request
.get('/api/admin/ui-config') .get('/api/admin/ui-config')
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200); .expect(200);
expect(body.disablePasswordAuth).toBe(true); expect(body.disablePasswordAuth).toBe(true);
}); });
test('gets ui config with frontendSettings', async () => {
const frontendApiOrigins = ['https://example.net'];
await app.services.settingService.setFrontendSettings(
{ frontendApiOrigins },
randomId(),
);
await app.request
.get('/api/admin/ui-config')
.expect('Content-Type', /json/)
.expect(200)
.expect((res) =>
expect(res.body.frontendApiOrigins).toEqual(frontendApiOrigins),
);
});
test('sets ui config with frontendSettings', async () => {
const frontendApiOrigins = ['https://example.org'];
await app.request
.get('/api/admin/ui-config')
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => expect(res.body.frontendApiOrigins).toEqual(['*']));
await app.request
.post('/api/admin/ui-config')
.send({ frontendSettings: { frontendApiOrigins: [] } })
.expect(204);
await app.request
.get('/api/admin/ui-config')
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => expect(res.body.frontendApiOrigins).toEqual([]));
await app.request
.post('/api/admin/ui-config')
.send({ frontendSettings: { frontendApiOrigins } })
.expect(204);
await app.request
.get('/api/admin/ui-config')
.expect('Content-Type', /json/)
.expect(200)
.expect((res) =>
expect(res.body.frontendApiOrigins).toEqual(frontendApiOrigins),
);
});

View File

@ -11,9 +11,7 @@ let defaultToken;
beforeAll(async () => { beforeAll(async () => {
db = await dbInit('metrics_two_api_client', getLogger); db = await dbInit('metrics_two_api_client', getLogger);
app = await setupAppWithAuth(db.stores, { app = await setupAppWithAuth(db.stores, {});
experimental: { metricsV2: { enabled: true } },
});
defaultToken = await app.services.apiTokenService.createApiToken({ defaultToken = await app.services.apiTokenService.createApiToken({
type: ApiTokenType.CLIENT, type: ApiTokenType.CLIENT,
project: 'default', project: 'default',

View File

@ -2498,6 +2498,28 @@ Object {
}, },
"type": "array", "type": "array",
}, },
"setUiConfigSchema": Object {
"additionalProperties": false,
"properties": Object {
"frontendSettings": Object {
"additionalProperties": false,
"properties": Object {
"frontendApiOrigins": Object {
"additionalProperties": false,
"items": Object {
"type": "string",
},
"type": "array",
},
},
"required": Array [
"frontendApiOrigins",
],
"type": "object",
},
},
"type": "object",
},
"sortOrderSchema": Object { "sortOrderSchema": Object {
"additionalProperties": Object { "additionalProperties": Object {
"type": "number", "type": "number",
@ -2829,6 +2851,12 @@ Object {
}, },
"type": "object", "type": "object",
}, },
"frontendApiOrigins": Object {
"items": Object {
"type": "string",
},
"type": "array",
},
"links": Object { "links": Object {
"items": Object { "items": Object {
"type": "object", "type": "object",
@ -6172,7 +6200,7 @@ If the provided project does not exist, the list of events will be empty.",
}, },
"/api/admin/ui-config": Object { "/api/admin/ui-config": Object {
"get": Object { "get": Object {
"operationId": "getUIConfig", "operationId": "getUiConfig",
"responses": Object { "responses": Object {
"200": Object { "200": Object {
"content": Object { "content": Object {
@ -6189,6 +6217,28 @@ If the provided project does not exist, the list of events will be empty.",
"Admin UI", "Admin UI",
], ],
}, },
"post": Object {
"operationId": "setUiConfig",
"requestBody": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/setUiConfigSchema",
},
},
},
"description": "setUiConfigSchema",
"required": true,
},
"responses": Object {
"200": Object {
"description": "This response has no body.",
},
},
"tags": Array [
"Admin UI",
],
},
}, },
"/api/admin/user": Object { "/api/admin/user": Object {
"get": Object { "get": Object {

View File

@ -254,8 +254,18 @@ test('should add a member user to the project', async () => {
const memberRole = await stores.roleStore.getRoleByName(RoleName.MEMBER); const memberRole = await stores.roleStore.getRoleByName(RoleName.MEMBER);
await projectService.addUser(project.id, memberRole.id, projectMember1.id); await projectService.addUser(
await projectService.addUser(project.id, memberRole.id, projectMember2.id); project.id,
memberRole.id,
projectMember1.id,
'test',
);
await projectService.addUser(
project.id,
memberRole.id,
projectMember2.id,
'test',
);
const { users } = await projectService.getAccessToProject(project.id); const { users } = await projectService.getAccessToProject(project.id);
const memberUsers = users.filter((u) => u.roleId === memberRole.id); const memberUsers = users.filter((u) => u.roleId === memberRole.id);
@ -286,8 +296,18 @@ test('should add admin users to the project', async () => {
const ownerRole = await stores.roleStore.getRoleByName(RoleName.OWNER); const ownerRole = await stores.roleStore.getRoleByName(RoleName.OWNER);
await projectService.addUser(project.id, ownerRole.id, projectAdmin1.id); await projectService.addUser(
await projectService.addUser(project.id, ownerRole.id, projectAdmin2.id); project.id,
ownerRole.id,
projectAdmin1.id,
'test',
);
await projectService.addUser(
project.id,
ownerRole.id,
projectAdmin2.id,
'test',
);
const { users } = await projectService.getAccessToProject(project.id); const { users } = await projectService.getAccessToProject(project.id);
@ -315,10 +335,20 @@ test('add user should fail if user already have access', async () => {
const memberRole = await stores.roleStore.getRoleByName(RoleName.MEMBER); const memberRole = await stores.roleStore.getRoleByName(RoleName.MEMBER);
await projectService.addUser(project.id, memberRole.id, projectMember1.id); await projectService.addUser(
project.id,
memberRole.id,
projectMember1.id,
'test',
);
await expect(async () => await expect(async () =>
projectService.addUser(project.id, memberRole.id, projectMember1.id), projectService.addUser(
project.id,
memberRole.id,
projectMember1.id,
'test',
),
).rejects.toThrow( ).rejects.toThrow(
new Error('User already has access to project=add-users-twice'), new Error('User already has access to project=add-users-twice'),
); );
@ -339,11 +369,17 @@ test('should remove user from the project', async () => {
const memberRole = await stores.roleStore.getRoleByName(RoleName.MEMBER); const memberRole = await stores.roleStore.getRoleByName(RoleName.MEMBER);
await projectService.addUser(project.id, memberRole.id, projectMember1.id); await projectService.addUser(
project.id,
memberRole.id,
projectMember1.id,
'test',
);
await projectService.removeUser( await projectService.removeUser(
project.id, project.id,
memberRole.id, memberRole.id,
projectMember1.id, projectMember1.id,
'test',
); );
const { users } = await projectService.getAccessToProject(project.id); const { users } = await projectService.getAccessToProject(project.id);
@ -364,7 +400,12 @@ test('should not remove user from the project', async () => {
const ownerRole = roles.find((r) => r.name === RoleName.OWNER); const ownerRole = roles.find((r) => r.name === RoleName.OWNER);
await expect(async () => { await expect(async () => {
await projectService.removeUser(project.id, ownerRole.id, user.id); await projectService.removeUser(
project.id,
ownerRole.id,
user.id,
'test',
);
}).rejects.toThrowError( }).rejects.toThrowError(
new Error('A project must have at least one owner'), new Error('A project must have at least one owner'),
); );
@ -617,7 +658,12 @@ test('should add a user to the project with a custom role', async () => {
], ],
}); });
await projectService.addUser(project.id, customRole.id, projectMember1.id); await projectService.addUser(
project.id,
customRole.id,
projectMember1.id,
'test',
);
const { users } = await projectService.getAccessToProject(project.id); const { users } = await projectService.getAccessToProject(project.id);
@ -668,8 +714,8 @@ test('should delete role entries when deleting project', async () => {
], ],
}); });
await projectService.addUser(project.id, customRole.id, user1.id); await projectService.addUser(project.id, customRole.id, user1.id, 'test');
await projectService.addUser(project.id, customRole.id, user2.id); await projectService.addUser(project.id, customRole.id, user2.id, 'test');
let usersForRole = await accessService.getUsersForRole(customRole.id); let usersForRole = await accessService.getUsersForRole(customRole.id);
expect(usersForRole.length).toBe(2); expect(usersForRole.length).toBe(2);
@ -715,15 +761,25 @@ test('should change a users role in the project', async () => {
}); });
const member = await stores.roleStore.getRoleByName(RoleName.MEMBER); const member = await stores.roleStore.getRoleByName(RoleName.MEMBER);
await projectService.addUser(project.id, member.id, projectUser.id); await projectService.addUser(project.id, member.id, projectUser.id, 'test');
const { users } = await projectService.getAccessToProject(project.id); const { users } = await projectService.getAccessToProject(project.id);
let memberUser = users.filter((u) => u.roleId === member.id); let memberUser = users.filter((u) => u.roleId === member.id);
expect(memberUser).toHaveLength(1); expect(memberUser).toHaveLength(1);
expect(memberUser[0].id).toBe(projectUser.id); expect(memberUser[0].id).toBe(projectUser.id);
expect(memberUser[0].name).toBe(projectUser.name); expect(memberUser[0].name).toBe(projectUser.name);
await projectService.removeUser(project.id, member.id, projectUser.id); await projectService.removeUser(
await projectService.addUser(project.id, customRole.id, projectUser.id); project.id,
member.id,
projectUser.id,
'test',
);
await projectService.addUser(
project.id,
customRole.id,
projectUser.id,
'test',
);
let { users: updatedUsers } = await projectService.getAccessToProject( let { users: updatedUsers } = await projectService.getAccessToProject(
project.id, project.id,
@ -751,7 +807,12 @@ test('should update role for user on project', async () => {
const memberRole = await stores.roleStore.getRoleByName(RoleName.MEMBER); const memberRole = await stores.roleStore.getRoleByName(RoleName.MEMBER);
const ownerRole = await stores.roleStore.getRoleByName(RoleName.OWNER); const ownerRole = await stores.roleStore.getRoleByName(RoleName.OWNER);
await projectService.addUser(project.id, memberRole.id, projectMember1.id); await projectService.addUser(
project.id,
memberRole.id,
projectMember1.id,
'test',
);
await projectService.changeRole( await projectService.changeRole(
project.id, project.id,
ownerRole.id, ownerRole.id,
@ -788,7 +849,12 @@ test('should able to assign role without existing members', async () => {
const memberRole = await stores.roleStore.getRoleByName(RoleName.MEMBER); const memberRole = await stores.roleStore.getRoleByName(RoleName.MEMBER);
await projectService.addUser(project.id, memberRole.id, projectMember1.id); await projectService.addUser(
project.id,
memberRole.id,
projectMember1.id,
'test',
);
await projectService.changeRole( await projectService.changeRole(
project.id, project.id,
testRole.id, testRole.id,
@ -819,7 +885,12 @@ test('should not update role for user on project when she is the owner', async (
const memberRole = await stores.roleStore.getRoleByName(RoleName.MEMBER); const memberRole = await stores.roleStore.getRoleByName(RoleName.MEMBER);
await projectService.addUser(project.id, memberRole.id, projectMember1.id); await projectService.addUser(
project.id,
memberRole.id,
projectMember1.id,
'test',
);
await expect(async () => { await expect(async () => {
await projectService.changeRole( await projectService.changeRole(

View File

@ -11,9 +11,10 @@ import NotFoundError from '../../../lib/error/notfound-error';
import { IRole } from '../../../lib/types/stores/access-store'; import { IRole } from '../../../lib/types/stores/access-store';
import { RoleName } from '../../../lib/types/model'; import { RoleName } from '../../../lib/types/model';
import SettingService from '../../../lib/services/setting-service'; import SettingService from '../../../lib/services/setting-service';
import { simpleAuthKey } from '../../../lib/types/settings/simple-auth-settings'; import { simpleAuthSettingsKey } from '../../../lib/types/settings/simple-auth-settings';
import { addDays, minutesToMilliseconds } from 'date-fns'; import { addDays, minutesToMilliseconds } from 'date-fns';
import { GroupService } from '../../../lib/services/group-service'; import { GroupService } from '../../../lib/services/group-service';
import { randomId } from '../../../lib/util/random-id';
let db; let db;
let stores; let stores;
@ -22,6 +23,7 @@ let userStore: UserStore;
let adminRole: IRole; let adminRole: IRole;
let viewerRole: IRole; let viewerRole: IRole;
let sessionService: SessionService; let sessionService: SessionService;
let settingService: SettingService;
beforeAll(async () => { beforeAll(async () => {
db = await dbInit('user_service_serial', getLogger); db = await dbInit('user_service_serial', getLogger);
@ -32,7 +34,7 @@ beforeAll(async () => {
const resetTokenService = new ResetTokenService(stores, config); const resetTokenService = new ResetTokenService(stores, config);
const emailService = new EmailService(undefined, config.getLogger); const emailService = new EmailService(undefined, config.getLogger);
sessionService = new SessionService(stores, config); sessionService = new SessionService(stores, config);
const settingService = new SettingService(stores, config); settingService = new SettingService(stores, config);
userService = new UserService(stores, config, { userService = new UserService(stores, config, {
accessService, accessService,
@ -101,7 +103,11 @@ test('should create user with password', async () => {
}); });
test('should not login user if simple auth is disabled', async () => { test('should not login user if simple auth is disabled', async () => {
await db.stores.settingStore.insert(simpleAuthKey, { disabled: true }); await settingService.insert(
simpleAuthSettingsKey,
{ disabled: true },
randomId(),
);
await userService.createUser({ await userService.createUser({
username: 'test_no_pass', username: 'test_no_pass',

View File

@ -36,13 +36,16 @@
"url-loader": "4.1.1" "url-loader": "4.1.1"
}, },
"resolutions": { "resolutions": {
"async": "^3.2.4",
"trim": "^1.0.0", "trim": "^1.0.0",
"glob-parent": "^6.0.0", "glob-parent": "^6.0.0",
"browserslist": "^4.16.5", "browserslist": "^4.16.5",
"set-value": "^4.0.1", "set-value": "^4.0.1",
"immer": "^9.0.6", "immer": "^9.0.6",
"ansi-regex": "^5.0.1", "ansi-regex": "^5.0.1",
"nth-check": "^2.0.1" "nth-check": "^2.0.1",
"shelljs": "^0.8.5",
"trim-newlines": "^3.0.1"
}, },
"browserslist": { "browserslist": {
"production": [ "production": [
@ -70,6 +73,6 @@
"enhanced-resolve": "5.10.0", "enhanced-resolve": "5.10.0",
"react-router": "6.3.0", "react-router": "6.3.0",
"storybook-addon-root-attribute": "1.0.2", "storybook-addon-root-attribute": "1.0.2",
"typescript": "4.7.4" "typescript": "4.8.2"
} }
} }

View File

@ -4583,17 +4583,10 @@ async-each@^1.0.1:
resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf" resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf"
integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ== integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==
async@2.6.3: async@2.6.3, async@3.2.1, async@^3.2.4:
version "2.6.3" version "3.2.4"
resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff" resolved "https://registry.yarnpkg.com/async/-/async-3.2.4.tgz#2d22e00f8cddeb5fde5dd33522b56d1cf569a81c"
integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg== integrity sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==
dependencies:
lodash "^4.17.14"
async@3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/async/-/async-3.2.1.tgz#d3274ec66d107a47476a4c49136aacdb00665fc8"
integrity sha512-XdD5lRO/87udXCMC9meWdYiR+Nq6ZjUfXidViUZGu2F1MO4T3XwZ1et0hb2++BgLfhyJwy44BGB/yx80ABx8hg==
asynckit@^0.4.0: asynckit@^0.4.0:
version "0.4.0" version "0.4.0"
@ -9200,7 +9193,7 @@ lodash.uniq@4.5.0, lodash.uniq@^4.5.0:
resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ== integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==
lodash@4.17.21, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4: lodash@4.17.21, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4:
version "4.17.21" version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
@ -12649,16 +12642,7 @@ shell-quote@^1.7.3:
resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.7.3.tgz#aa40edac170445b9a431e17bb62c0b881b9c4123" resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.7.3.tgz#aa40edac170445b9a431e17bb62c0b881b9c4123"
integrity sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw== integrity sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==
shelljs@0.8.4: shelljs@0.8.4, shelljs@^0.8.5:
version "0.8.4"
resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.4.tgz#de7684feeb767f8716b326078a8a00875890e3c2"
integrity sha512-7gk3UZ9kOfPLIAbslLzyWeGiEqx9e3rxwZM0KE6EL8GlGwjym9Mrlx5/p33bWTu9YG6vcS4MBxYZDHYr5lr8BQ==
dependencies:
glob "^7.0.0"
interpret "^1.0.0"
rechoir "^0.6.2"
shelljs@^0.8.5:
version "0.8.5" version "0.8.5"
resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.5.tgz#de055408d8361bed66c669d2f000538ced8ee20c" resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.5.tgz#de055408d8361bed66c669d2f000538ced8ee20c"
integrity sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow== integrity sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==
@ -13549,10 +13533,10 @@ trim-lines@^3.0.0:
resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-3.0.1.tgz#d802e332a07df861c48802c04321017b1bd87338" resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-3.0.1.tgz#d802e332a07df861c48802c04321017b1bd87338"
integrity sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg== integrity sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==
trim-newlines@^1.0.0: trim-newlines@^1.0.0, trim-newlines@^3.0.1:
version "1.0.0" version "3.0.1"
resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613" resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.1.tgz#260a5d962d8b752425b32f3a7db0dcacd176c144"
integrity sha512-Nm4cF79FhSTzrLKGDMi3I4utBtFv8qKy4sq1enftf2gMdpqI8oVQTAfySkTz5r49giVzDj88SVZXP4CeYQwjaw== integrity sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==
trim-trailing-lines@^1.0.0: trim-trailing-lines@^1.0.0:
version "1.1.4" version "1.1.4"
@ -13651,10 +13635,10 @@ typedarray@^0.0.6:
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==
typescript@4.7.4: typescript@4.8.2:
version "4.7.4" version "4.8.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.2.tgz#e3b33d5ccfb5914e4eeab6699cf208adee3fd790"
integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ== integrity sha512-C0I1UsrrDHo2fYI5oaCGbSejwX4ch+9Y5jTQELvovfmFkK3HHSZJB8MSJcWLmCUBzQBchCrZ9rMRV6GuNrvGtw==
ua-parser-js@^0.7.30: ua-parser-js@^0.7.30:
version "0.7.31" version "0.7.31"

107
yarn.lock
View File

@ -1352,14 +1352,14 @@
dependencies: dependencies:
"@types/yargs-parser" "*" "@types/yargs-parser" "*"
"@typescript-eslint/eslint-plugin@5.34.0": "@typescript-eslint/eslint-plugin@5.35.1":
version "5.34.0" version "5.35.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.34.0.tgz#d690f60e335596f38b01792e8f4b361d9bd0cb35" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.35.1.tgz#0d822bfea7469904dfc1bb8f13cabd362b967c93"
integrity sha512-eRfPPcasO39iwjlUAMtjeueRGuIrW3TQ9WseIDl7i5UWuFbf83yYaU7YPs4j8+4CxUMIsj1k+4kV+E+G+6ypDQ== integrity sha512-RBZZXZlI4XCY4Wzgy64vB+0slT9+yAPQRjj/HSaRwUot33xbDjF1oN9BLwOLTewoOI0jothIltZRe9uJCHf8gg==
dependencies: dependencies:
"@typescript-eslint/scope-manager" "5.34.0" "@typescript-eslint/scope-manager" "5.35.1"
"@typescript-eslint/type-utils" "5.34.0" "@typescript-eslint/type-utils" "5.35.1"
"@typescript-eslint/utils" "5.34.0" "@typescript-eslint/utils" "5.35.1"
debug "^4.3.4" debug "^4.3.4"
functional-red-black-tree "^1.0.1" functional-red-black-tree "^1.0.1"
ignore "^5.2.0" ignore "^5.2.0"
@ -1367,69 +1367,69 @@
semver "^7.3.7" semver "^7.3.7"
tsutils "^3.21.0" tsutils "^3.21.0"
"@typescript-eslint/parser@5.34.0": "@typescript-eslint/parser@5.35.1":
version "5.34.0" version "5.35.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.34.0.tgz#ca710858ea85dbfd30c9b416a335dc49e82dbc07" resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.35.1.tgz#bf2ee2ebeaa0a0567213748243fb4eec2857f04f"
integrity sha512-SZ3NEnK4usd2CXkoV3jPa/vo1mWX1fqRyIVUQZR4As1vyp4fneknBNJj+OFtV8WAVgGf+rOHMSqQbs2Qn3nFZQ== integrity sha512-XL2TBTSrh3yWAsMYpKseBYTVpvudNf69rPOWXWVBI08My2JVT5jR66eTt4IgQFHA/giiKJW5dUD4x/ZviCKyGg==
dependencies: dependencies:
"@typescript-eslint/scope-manager" "5.34.0" "@typescript-eslint/scope-manager" "5.35.1"
"@typescript-eslint/types" "5.34.0" "@typescript-eslint/types" "5.35.1"
"@typescript-eslint/typescript-estree" "5.34.0" "@typescript-eslint/typescript-estree" "5.35.1"
debug "^4.3.4" debug "^4.3.4"
"@typescript-eslint/scope-manager@5.34.0": "@typescript-eslint/scope-manager@5.35.1":
version "5.34.0" version "5.35.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.34.0.tgz#14efd13dc57602937e25f188fd911f118781e527" resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.35.1.tgz#ccb69d54b7fd0f2d0226a11a75a8f311f525ff9e"
integrity sha512-HNvASMQlah5RsBW6L6c7IJ0vsm+8Sope/wu5sEAf7joJYWNb1LDbJipzmdhdUOnfrDFE6LR1j57x1EYVxrY4ow== integrity sha512-kCYRSAzIW9ByEIzmzGHE50NGAvAP3wFTaZevgWva7GpquDyFPFcmvVkFJGWJJktg/hLwmys/FZwqM9EKr2u24Q==
dependencies: dependencies:
"@typescript-eslint/types" "5.34.0" "@typescript-eslint/types" "5.35.1"
"@typescript-eslint/visitor-keys" "5.34.0" "@typescript-eslint/visitor-keys" "5.35.1"
"@typescript-eslint/type-utils@5.34.0": "@typescript-eslint/type-utils@5.35.1":
version "5.34.0" version "5.35.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.34.0.tgz#7a324ab9ddd102cd5e1beefc94eea6f3eb32d32d" resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.35.1.tgz#d50903b56758c5c8fc3be52b3be40569f27f9c4a"
integrity sha512-Pxlno9bjsQ7hs1pdWRUv9aJijGYPYsHpwMeCQ/Inavhym3/XaKt1ZKAA8FIw4odTBfowBdZJDMxf2aavyMDkLg== integrity sha512-8xT8ljvo43Mp7BiTn1vxLXkjpw8wS4oAc00hMSB4L1/jIiYbjjnc3Qp2GAUOG/v8zsNCd1qwcqfCQ0BuishHkw==
dependencies: dependencies:
"@typescript-eslint/utils" "5.34.0" "@typescript-eslint/utils" "5.35.1"
debug "^4.3.4" debug "^4.3.4"
tsutils "^3.21.0" tsutils "^3.21.0"
"@typescript-eslint/types@5.34.0": "@typescript-eslint/types@5.35.1":
version "5.34.0" version "5.35.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.34.0.tgz#217bf08049e9e7b86694d982e88a2c1566330c78" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.35.1.tgz#af355fe52a0cc88301e889bc4ada72f279b63d61"
integrity sha512-49fm3xbbUPuzBIOcy2CDpYWqy/X7VBkxVN+DC21e0zIm3+61Z0NZi6J9mqPmSW1BDVk9FIOvuCFyUPjXz93sjA== integrity sha512-FDaujtsH07VHzG0gQ6NDkVVhi1+rhq0qEvzHdJAQjysN+LHDCKDKCBRlZFFE0ec0jKxiv0hN63SNfExy0KrbQQ==
"@typescript-eslint/typescript-estree@5.34.0": "@typescript-eslint/typescript-estree@5.35.1":
version "5.34.0" version "5.35.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.34.0.tgz#ba7b83f4bf8ccbabf074bbf1baca7a58de3ccb9a" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.35.1.tgz#db878a39a0dbdc9bb133f11cdad451770bfba211"
integrity sha512-mXHAqapJJDVzxauEkfJI96j3D10sd567LlqroyCeJaHnu42sDbjxotGb3XFtGPYKPD9IyLjhsoULML1oI3M86A== integrity sha512-JUqE1+VRTGyoXlDWWjm6MdfpBYVq+hixytrv1oyjYIBEOZhBCwtpp5ZSvBt4wIA1MKWlnaC2UXl2XmYGC3BoQA==
dependencies: dependencies:
"@typescript-eslint/types" "5.34.0" "@typescript-eslint/types" "5.35.1"
"@typescript-eslint/visitor-keys" "5.34.0" "@typescript-eslint/visitor-keys" "5.35.1"
debug "^4.3.4" debug "^4.3.4"
globby "^11.1.0" globby "^11.1.0"
is-glob "^4.0.3" is-glob "^4.0.3"
semver "^7.3.7" semver "^7.3.7"
tsutils "^3.21.0" tsutils "^3.21.0"
"@typescript-eslint/utils@5.34.0": "@typescript-eslint/utils@5.35.1":
version "5.34.0" version "5.35.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.34.0.tgz#0cae98f48d8f9e292e5caa9343611b6faf49e743" resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.35.1.tgz#ae1399afbfd6aa7d0ed1b7d941e9758d950250eb"
integrity sha512-kWRYybU4Rn++7lm9yu8pbuydRyQsHRoBDIo11k7eqBWTldN4xUdVUMCsHBiE7aoEkFzrUEaZy3iH477vr4xHAQ== integrity sha512-v6F8JNXgeBWI4pzZn36hT2HXXzoBBBJuOYvoQiaQaEEjdi5STzux3Yj8v7ODIpx36i/5s8TdzuQ54TPc5AITQQ==
dependencies: dependencies:
"@types/json-schema" "^7.0.9" "@types/json-schema" "^7.0.9"
"@typescript-eslint/scope-manager" "5.34.0" "@typescript-eslint/scope-manager" "5.35.1"
"@typescript-eslint/types" "5.34.0" "@typescript-eslint/types" "5.35.1"
"@typescript-eslint/typescript-estree" "5.34.0" "@typescript-eslint/typescript-estree" "5.35.1"
eslint-scope "^5.1.1" eslint-scope "^5.1.1"
eslint-utils "^3.0.0" eslint-utils "^3.0.0"
"@typescript-eslint/visitor-keys@5.34.0": "@typescript-eslint/visitor-keys@5.35.1":
version "5.34.0" version "5.35.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.34.0.tgz#d0fb3e31033e82ddd5de048371ad39eb342b2d40" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.35.1.tgz#285e9e34aed7c876f16ff646a3984010035898e6"
integrity sha512-O1moYjOSrab0a2fUvFpsJe0QHtvTC+cR+ovYpgKrAVXzqQyc74mv76TgY6z+aEtjQE2vgZux3CQVtGryqdcOAw== integrity sha512-cEB1DvBVo1bxbW/S5axbGPE6b7FIMAbo3w+AGq6zNDA7+NYJOIkKj/sInfTv4edxd4PxJSgdN4t6/pbvgA+n5g==
dependencies: dependencies:
"@typescript-eslint/types" "5.34.0" "@typescript-eslint/types" "5.35.1"
eslint-visitor-keys "^3.3.0" eslint-visitor-keys "^3.3.0"
"@unleash/express-openapi@^0.2.0": "@unleash/express-openapi@^0.2.0":
@ -7187,10 +7187,10 @@ typedarray@^0.0.6:
resolved "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz" resolved "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz"
integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
typescript@4.7.4: typescript@4.8.2:
version "4.7.4" version "4.8.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.2.tgz#e3b33d5ccfb5914e4eeab6699cf208adee3fd790"
integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ== integrity sha512-C0I1UsrrDHo2fYI5oaCGbSejwX4ch+9Y5jTQELvovfmFkK3HHSZJB8MSJcWLmCUBzQBchCrZ9rMRV6GuNrvGtw==
uid-safe@~2.1.5: uid-safe@~2.1.5:
version "2.1.5" version "2.1.5"
@ -7243,6 +7243,11 @@ unleash-client@3.15.0:
murmurhash3js "^3.0.1" murmurhash3js "^3.0.1"
semver "^7.3.5" semver "^7.3.5"
unleash-frontend@4.14.8:
version "4.14.8"
resolved "https://registry.yarnpkg.com/unleash-frontend/-/unleash-frontend-4.14.8.tgz#c31ded48d8f6de859bde39833fc78ad8bd74c770"
integrity sha512-CcqyFhIZyb8qCHe6iX3saHTdIfN0TzAES2PaSWDCRPt17L9KcV1t+fns1nFvCIzH/XmM416uQC4HgKyoLtR9tw==
unpipe@1.0.0, unpipe@~1.0.0: unpipe@1.0.0, unpipe@~1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz" resolved "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz"