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",
"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": [
"unleash",
"feature toggle",
@ -127,6 +127,7 @@
"ts-toolbelt": "^9.6.0",
"type-is": "^1.6.18",
"unleash-client": "3.15.0",
"unleash-frontend": "4.14.8",
"uuid": "^8.3.2"
},
"devDependencies": {
@ -150,8 +151,8 @@
"@types/supertest": "2.0.12",
"@types/type-is": "1.6.3",
"@types/uuid": "8.3.4",
"@typescript-eslint/eslint-plugin": "5.34.0",
"@typescript-eslint/parser": "5.34.0",
"@typescript-eslint/eslint-plugin": "5.35.1",
"@typescript-eslint/parser": "5.35.1",
"copyfiles": "2.4.1",
"coveralls": "3.1.1",
"del-cli": "5.0.0",
@ -176,7 +177,7 @@
"ts-jest": "27.1.5",
"ts-node": "10.9.1",
"tsc-watch": "5.0.3",
"typescript": "4.7.4"
"typescript": "4.8.2"
},
"resolutions": {
"async": "^3.2.4",

View File

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

View File

@ -62,10 +62,30 @@ Object {
},
"eventHook": undefined,
"experimental": Object {
"batchMetrics": false,
"embedProxy": false,
"externalResolver": Object {
"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],
"import": Object {
"dropBeforeImport": false,
@ -106,6 +126,7 @@ Object {
"ui": Object {
"flags": Object {
"E": true,
"ENABLE_DARK_MODE_SUPPORT": false,
},
},
"versionCheck": Object {

View File

@ -70,16 +70,13 @@ export default async function getApp(
}
if (
config.experimental.embedProxy &&
config.experimental.flags.embedProxy &&
config.frontendApiOrigins.length > 0
) {
// Support CORS preflight requests for the frontend endpoints.
// Preflight requests should not have Authorization headers,
// so this must be handled before the API token middleware.
app.options(
'/api/frontend*',
corsOriginMiddleware(config.frontendApiOrigins),
);
app.options('/api/frontend*', corsOriginMiddleware(services));
}
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.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,
parseEnvVarStrings,
} from './util/parseEnvVar';
import { IExperimentalOptions } from './experimental';
import {
defaultExperimentalOptions,
IExperimentalOptions,
} from './types/experimental';
import {
DEFAULT_SEGMENT_VALUES_LIMIT,
DEFAULT_STRATEGY_SEGMENTS_LIMIT,
} from './util/segments';
import FlagResolver from './util/flag-resolver';
import { validateOrigins } from './util/validateOrigin';
const safeToUpper = (s: string) => (s ? s.toUpperCase() : s);
@ -55,15 +60,12 @@ function mergeAll<T>(objects: Partial<T>[]): T {
function loadExperimental(options: IUnleashOptions): IExperimentalOptions {
return {
...defaultExperimentalOptions,
...options.experimental,
embedProxy: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_EMBED_PROXY,
Boolean(options.experimental?.embedProxy),
),
batchMetrics: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_BATCH_METRICS,
Boolean(options.experimental?.batchMetrics),
),
flags: {
...defaultExperimentalOptions.flags,
...options.experimental?.flags,
},
};
}
@ -102,6 +104,7 @@ function loadUI(options: IUnleashOptions): IUIConfig {
ui.flags = {
E: true,
ENABLE_DARK_MODE_SUPPORT: false,
};
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 {
let extraDbOptions = {};
@ -375,6 +392,7 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
]);
const experimental = loadExperimental(options);
const flagResolver = new FlagResolver(experimental);
const ui = loadUI(options);
@ -417,10 +435,6 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
DEFAULT_STRATEGY_SEGMENTS_LIMIT,
);
const frontendApiOrigins =
options.frontendApiOrigins ||
parseEnvVarStrings(process.env.UNLEASH_FRONTEND_API_ORIGINS, []);
const clientFeatureCaching = loadClientCachingOptions(options);
return {
@ -434,6 +448,7 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
ui,
import: importSetting,
experimental,
flagResolver,
email,
secureHeaders,
enableOAS,
@ -445,7 +460,7 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
eventBus: new EventEmitter(),
environmentEnableOverrides,
additionalCspAllowedDomains,
frontendApiOrigins,
frontendApiOrigins: parseFrontendApiOrigins(options),
inlineSegmentConstraints,
segmentValuesLimit,
strategySegmentsLimit,

View File

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

View File

@ -57,12 +57,7 @@ export const createStores = (
tagStore: new TagStore(db, eventBus, getLogger),
tagTypeStore: new TagTypeStore(db, eventBus, getLogger),
addonStore: new AddonStore(db, eventBus, getLogger),
accessStore: new AccessStore(
db,
eventBus,
getLogger,
config?.experimental?.userGroups,
),
accessStore: new AccessStore(db, eventBus, getLogger),
apiTokenStore: new ApiTokenStore(db, eventBus, getLogger),
resetTokenStore: new ResetTokenStore(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 (
(apiUser.type === CLIENT && !isClientApi(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 });
return;

View File

@ -1,4 +1,21 @@
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', () => {
const dotCom = 'https://example.com';
@ -16,3 +33,54 @@ test('allowRequestOrigin', () => {
expect(allowRequestOrigin(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 cors from 'cors';
const ANY_ORIGIN = '*';
import { IUnleashServices } from '../types';
export const allowRequestOrigin = (
requestOrigin: string,
allowedOrigins: string[],
): boolean => {
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.
// The list may include '*', which `cors` does not support natively.
export const corsOriginMiddleware = (
allowedOrigins: string[],
): RequestHandler => {
return cors((req, callback) => {
callback(null, {
origin: allowRequestOrigin(req.header('Origin'), allowedOrigins),
});
export const corsOriginMiddleware = ({
settingService,
}: Pick<IUnleashServices, 'settingService'>): RequestHandler => {
return cors(async (req, callback) => {
try {
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 { proxyClientSchema } from './spec/proxy-client-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.
export const schemas = {
@ -187,6 +188,7 @@ export const schemas = {
searchEventsSchema,
segmentSchema,
setStrategySortOrderSchema,
setUiConfigSchema,
sortOrderSchema,
splashSchema,
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: {
type: 'number',
},
frontendApiOrigins: {
type: 'array',
items: {
type: 'string',
},
},
flags: {
type: 'object',
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 { IAuthType, IUnleashConfig } from '../../types/option';
import version from '../../util/version';
@ -6,10 +7,10 @@ import Controller from '../controller';
import VersionService from '../../services/version-service';
import SettingService from '../../services/setting-service';
import {
simpleAuthKey,
simpleAuthSettingsKey,
SimpleAuthSettings,
} 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 {
uiConfigSchema,
@ -17,6 +18,12 @@ import {
} from '../../openapi/spec/ui-config-schema';
import { OpenApiService } from '../../services/openapi-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 {
private versionService: VersionService;
@ -51,33 +58,56 @@ class ConfigController extends Controller {
this.route({
method: 'get',
path: '',
handler: this.getUIConfig,
handler: this.getUiConfig,
permission: NONE,
middleware: [
openApiService.validPath({
tags: ['Admin UI'],
operationId: 'getUIConfig',
operationId: 'getUiConfig',
responses: {
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(
req: Request,
async getUiConfig(
req: AuthedRequest,
res: Response<UiConfigSchema>,
): Promise<void> {
const simpleAuthSettings =
await this.settingService.get<SimpleAuthSettings>(simpleAuthKey);
const [frontendSettings, simpleAuthSettings] = await Promise.all([
this.settingService.getFrontendSettings(),
this.settingService.get<SimpleAuthSettings>(simpleAuthSettingsKey),
]);
const disablePasswordAuth =
simpleAuthSettings?.disabled ||
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 = {
...this.config.ui,
flags,
version,
emailEnabled: this.emailService.isEnabled(),
unleashUrl: this.config.server.unleashUrl,
@ -85,9 +115,10 @@ class ConfigController extends Controller {
authenticationType: this.config.authentication?.type,
segmentValuesLimit: this.config.segmentValuesLimit,
strategySegmentsLimit: this.config.strategySegmentsLimit,
frontendApiOrigins: frontendSettings.frontendApiOrigins,
versionInfo: this.versionService.getVersionInfo(),
disablePasswordAuth,
embedProxy: this.config.experimental.embedProxy,
embedProxy: this.config.experimental.flags.embedProxy,
};
this.openApiService.respondWithValidation(
@ -97,5 +128,22 @@ class ConfigController extends Controller {
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;

View File

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

View File

@ -12,7 +12,7 @@ async function getSetup(anonymise: boolean = false) {
const stores = createStores();
const config = createTestConfig({
server: { baseUriPath: base },
experimental: { anonymiseEventLog: anonymise },
experimental: { flags: { anonymiseEventLog: anonymise } },
});
const services = createServices(stores, config);
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 SettingService from '../../services/setting-service';
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 { OpenApiService } from '../../services/openapi-service';
import { createRequestSchema } from '../../openapi/util/create-request-schema';
@ -37,9 +37,10 @@ import {
usersGroupsBaseSchema,
} from '../../openapi/spec/users-groups-base-schema';
import { IGroup } from '../../types/group';
import { IFlagResolver } from '../../types/experimental';
export default class UserAdminController extends Controller {
private anonymise: boolean = false;
private flagResolver: IFlagResolver;
private userService: UserService;
@ -90,7 +91,7 @@ export default class UserAdminController extends Controller {
this.groupService = groupService;
this.logger = config.getLogger('routes/user-controller.ts');
this.unleashUrl = config.server.unleashUrl;
this.anonymise = config.experimental?.anonymiseEventLog;
this.flagResolver = config.flagResolver;
this.route({
method: 'post',
@ -294,7 +295,7 @@ export default class UserAdminController extends Controller {
typeof q === 'string' && q.length > 1
? await this.userService.search(q)
: [];
if (this.anonymise) {
if (this.flagResolver.isEnabled('anonymiseEventLog')) {
users = this.anonymiseUsers(users);
}
this.openApiService.respondWithValidation(
@ -368,7 +369,9 @@ export default class UserAdminController extends Controller {
);
const passwordAuthSettings =
await this.settingService.get<SimpleAuthSettings>(simpleAuthKey);
await this.settingService.get<SimpleAuthSettings>(
simpleAuthSettingsKey,
);
let inviteLink: string;
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 () => {
const testRunner = await getSetup({
experimental: { metricsV2: { enabled: true } },
});
const testRunner = await getSetup();
await testRunner.request
.post('/api/client/metrics')
.send({

View File

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

View File

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

View File

@ -16,7 +16,6 @@ import ApiUser from '../../types/api-user';
import { ALL } from '../../types/models/api-token';
import User from '../../types/user';
import { collapseHourlyMetrics } from '../../util/collapseHourlyMetrics';
import { IExperimentalOptions } from '../../experimental';
export default class ClientMetricsServiceV2 {
private timers: NodeJS.Timeout[] = [];
@ -27,7 +26,7 @@ export default class ClientMetricsServiceV2 {
private featureToggleStore: IFeatureToggleStore;
private experimental: IExperimentalOptions;
private batchMetricsEnabled: boolean;
private eventBus: EventEmitter;
@ -47,13 +46,13 @@ export default class ClientMetricsServiceV2 {
) {
this.featureToggleStore = featureToggleStore;
this.clientMetricsStoreV2 = clientMetricsStoreV2;
this.experimental = experimental;
this.batchMetricsEnabled = experimental.flags.batchMetrics;
this.eventBus = eventBus;
this.logger = getLogger(
'/services/client-metrics/client-metrics-service-v2.ts',
);
if (this.experimental.batchMetrics) {
if (this.batchMetricsEnabled) {
this.timers.push(
setInterval(() => {
this.bulkAdd().catch(console.error);
@ -91,7 +90,7 @@ export default class ClientMetricsServiceV2 {
}))
.filter((item) => !(item.yes === 0 && item.no === 0));
if (this.experimental.batchMetrics) {
if (this.batchMetricsEnabled) {
this.unsavedMetrics = collapseHourlyMetrics([
...this.unsavedMetrics,
...clientMetrics,
@ -104,7 +103,7 @@ export default class ClientMetricsServiceV2 {
}
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
// arrive while awaiting `batchInsertMetrics`.
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(
projectId: string,
roleId: number,
userId: number,
createdBy?: string,
createdBy: string,
): Promise<void> {
const [roles, users] = await this.accessService.getProjectRoleAccess(
projectId,
@ -323,7 +322,7 @@ export default class ProjectService {
await this.eventStore.store(
new ProjectUserAddedEvent({
project: projectId,
createdBy,
createdBy: createdBy || 'system-user',
data: {
roleId,
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(
projectId: string,
roleId: number,
userId: number,
createdBy?: string,
createdBy: string,
): Promise<void> {
const role = await this.findProjectRole(projectId, roleId);
@ -367,7 +365,7 @@ export default class ProjectService {
projectId: string,
roleId: number,
groupId: number,
modifiedBy?: string,
modifiedBy: string,
): Promise<void> {
const role = await this.accessService.getRole(roleId);
const group = await this.groupService.getGroup(groupId);
@ -397,7 +395,7 @@ export default class ProjectService {
projectId: string,
roleId: number,
groupId: number,
modifiedBy?: string,
modifiedBy: string,
): Promise<void> {
const group = await this.groupService.getGroup(groupId);
const role = await this.accessService.getRole(roleId);

View File

@ -8,31 +8,48 @@ import {
SettingDeletedEvent,
SettingUpdatedEvent,
} 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 {
private config: IUnleashConfig;
private logger: Logger;
private settingStore: ISettingStore;
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(
{
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.eventStore = eventStore;
}
async get<T>(id: string): Promise<T> {
return this.settingStore.get(id);
async get<T>(id: string, defaultValue?: T): Promise<T> {
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> {
this.cache.delete(id);
const exists = await this.settingStore.exists(id);
if (exists) {
await this.settingStore.updateRow(id, value);
@ -54,6 +71,7 @@ export default class SettingService {
}
async delete(id: string, createdBy: string): Promise<void> {
this.cache.delete(id);
await this.settingStore.delete(id);
await this.eventStore.store(
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;

View File

@ -22,7 +22,7 @@ import { IUserStore } from '../types/stores/user-store';
import { RoleName } from '../types/model';
import SettingService from './setting-service';
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 PasswordMismatch from '../error/password-mismatch';
import BadDataError from '../error/bad-data-error';
@ -276,7 +276,7 @@ class UserService {
async loginUser(usernameOrEmail: string, password: string): Promise<IUser> {
const settings = await this.settingService.get<SimpleAuthSettings>(
simpleAuthKey,
simpleAuthSettingsKey,
);
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 { LogLevel, LogProvider } from '../logger';
import { ILegacyApiTokenCreate } from './models/api-token';
import { IExperimentalOptions } from '../experimental';
import { IFlagResolver, IExperimentalOptions, IFlags } from './experimental';
import SMTPTransport from 'nodemailer/lib/smtp-transport';
export type EventHook = (eventName: string, data: object) => void;
@ -102,7 +102,7 @@ export interface IUnleashOptions {
authentication?: Partial<IAuthOption>;
ui?: object;
import?: Partial<IImportOption>;
experimental?: IExperimentalOptions;
experimental?: Partial<IExperimentalOptions>;
email?: Partial<IEmailOption>;
secureHeaders?: boolean;
additionalCspAllowedDomains?: ICspDomainOptions;
@ -139,7 +139,6 @@ export interface IListeningHost {
export interface IUIConfig {
slogan?: string;
name?: string;
flags?: { [key: string]: boolean };
links?: [
{
value: string;
@ -148,6 +147,7 @@ export interface IUIConfig {
title: string;
},
];
flags?: IFlags;
}
export interface ICspDomainOptions {
@ -177,6 +177,7 @@ export interface IUnleashConfig {
ui: IUIConfig;
import: IImportOption;
experimental?: IExperimentalOptions;
flagResolver: IFlagResolver;
email: IEmailOption;
secureHeaders: boolean;
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 {
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', () => {
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('a', ['*'])).toEqual(['a']);
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(
envVar: string,
envVar: string | undefined,
defaultVal: string[],
): string[] {
if (envVar) {
if (typeof envVar === 'string') {
return envVar
.split(',')
.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,
},
experimental: {
metricsV2: { enabled: true },
anonymiseEventLog: false,
userGroups: true,
embedProxy: true,
batchMetrics: true,
// externalResolver: unleash,
flags: {
embedProxy: true,
batchMetrics: true,
anonymiseEventLog: false,
},
},
authentication: {
initApiTokens: [

View File

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

View File

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

View File

@ -1,10 +1,11 @@
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 { 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 app;
let app: IUnleashTest;
beforeAll(async () => {
db = await dbInit('config_api_serial', getLogger);
@ -16,24 +17,71 @@ afterAll(async () => {
await db.destroy();
});
beforeEach(async () => {
await app.services.settingService.deleteAll();
});
test('gets ui config fields', async () => {
const { body } = await app.request
.get('/api/admin/ui-config')
.expect('Content-Type', /json/)
.expect(200);
expect(body.unleashUrl).toBe('http://localhost:4242');
expect(body.version).toBeDefined();
expect(body.emailEnabled).toBe(false);
});
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
.get('/api/admin/ui-config')
.expect('Content-Type', /json/)
.expect(200);
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 () => {
db = await dbInit('metrics_two_api_client', getLogger);
app = await setupAppWithAuth(db.stores, {
experimental: { metricsV2: { enabled: true } },
});
app = await setupAppWithAuth(db.stores, {});
defaultToken = await app.services.apiTokenService.createApiToken({
type: ApiTokenType.CLIENT,
project: 'default',

View File

@ -2498,6 +2498,28 @@ Object {
},
"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 {
"additionalProperties": Object {
"type": "number",
@ -2829,6 +2851,12 @@ Object {
},
"type": "object",
},
"frontendApiOrigins": Object {
"items": Object {
"type": "string",
},
"type": "array",
},
"links": Object {
"items": 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 {
"get": Object {
"operationId": "getUIConfig",
"operationId": "getUiConfig",
"responses": Object {
"200": Object {
"content": Object {
@ -6189,6 +6217,28 @@ If the provided project does not exist, the list of events will be empty.",
"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 {
"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);
await projectService.addUser(project.id, memberRole.id, projectMember1.id);
await projectService.addUser(project.id, memberRole.id, projectMember2.id);
await projectService.addUser(
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 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);
await projectService.addUser(project.id, ownerRole.id, projectAdmin1.id);
await projectService.addUser(project.id, ownerRole.id, projectAdmin2.id);
await projectService.addUser(
project.id,
ownerRole.id,
projectAdmin1.id,
'test',
);
await projectService.addUser(
project.id,
ownerRole.id,
projectAdmin2.id,
'test',
);
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);
await projectService.addUser(project.id, memberRole.id, projectMember1.id);
await projectService.addUser(
project.id,
memberRole.id,
projectMember1.id,
'test',
);
await expect(async () =>
projectService.addUser(project.id, memberRole.id, projectMember1.id),
projectService.addUser(
project.id,
memberRole.id,
projectMember1.id,
'test',
),
).rejects.toThrow(
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);
await projectService.addUser(project.id, memberRole.id, projectMember1.id);
await projectService.addUser(
project.id,
memberRole.id,
projectMember1.id,
'test',
);
await projectService.removeUser(
project.id,
memberRole.id,
projectMember1.id,
'test',
);
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);
await expect(async () => {
await projectService.removeUser(project.id, ownerRole.id, user.id);
await projectService.removeUser(
project.id,
ownerRole.id,
user.id,
'test',
);
}).rejects.toThrowError(
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);
@ -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, user2.id);
await projectService.addUser(project.id, customRole.id, user1.id, 'test');
await projectService.addUser(project.id, customRole.id, user2.id, 'test');
let usersForRole = await accessService.getUsersForRole(customRole.id);
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);
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);
let memberUser = users.filter((u) => u.roleId === member.id);
expect(memberUser).toHaveLength(1);
expect(memberUser[0].id).toBe(projectUser.id);
expect(memberUser[0].name).toBe(projectUser.name);
await projectService.removeUser(project.id, member.id, projectUser.id);
await projectService.addUser(project.id, customRole.id, projectUser.id);
await projectService.removeUser(
project.id,
member.id,
projectUser.id,
'test',
);
await projectService.addUser(
project.id,
customRole.id,
projectUser.id,
'test',
);
let { users: updatedUsers } = await projectService.getAccessToProject(
project.id,
@ -751,7 +807,12 @@ test('should update role for user on project', async () => {
const memberRole = await stores.roleStore.getRoleByName(RoleName.MEMBER);
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(
project.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);
await projectService.addUser(project.id, memberRole.id, projectMember1.id);
await projectService.addUser(
project.id,
memberRole.id,
projectMember1.id,
'test',
);
await projectService.changeRole(
project.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);
await projectService.addUser(project.id, memberRole.id, projectMember1.id);
await projectService.addUser(
project.id,
memberRole.id,
projectMember1.id,
'test',
);
await expect(async () => {
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 { RoleName } from '../../../lib/types/model';
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 { GroupService } from '../../../lib/services/group-service';
import { randomId } from '../../../lib/util/random-id';
let db;
let stores;
@ -22,6 +23,7 @@ let userStore: UserStore;
let adminRole: IRole;
let viewerRole: IRole;
let sessionService: SessionService;
let settingService: SettingService;
beforeAll(async () => {
db = await dbInit('user_service_serial', getLogger);
@ -32,7 +34,7 @@ beforeAll(async () => {
const resetTokenService = new ResetTokenService(stores, config);
const emailService = new EmailService(undefined, config.getLogger);
sessionService = new SessionService(stores, config);
const settingService = new SettingService(stores, config);
settingService = new SettingService(stores, config);
userService = new UserService(stores, config, {
accessService,
@ -101,7 +103,11 @@ test('should create user with password', 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({
username: 'test_no_pass',

View File

@ -36,13 +36,16 @@
"url-loader": "4.1.1"
},
"resolutions": {
"async": "^3.2.4",
"trim": "^1.0.0",
"glob-parent": "^6.0.0",
"browserslist": "^4.16.5",
"set-value": "^4.0.1",
"immer": "^9.0.6",
"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": {
"production": [
@ -70,6 +73,6 @@
"enhanced-resolve": "5.10.0",
"react-router": "6.3.0",
"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"
integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==
async@2.6.3:
version "2.6.3"
resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff"
integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==
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==
async@2.6.3, async@3.2.1, async@^3.2.4:
version "3.2.4"
resolved "https://registry.yarnpkg.com/async/-/async-3.2.4.tgz#2d22e00f8cddeb5fde5dd33522b56d1cf569a81c"
integrity sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==
asynckit@^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"
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"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
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"
integrity sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==
shelljs@0.8.4:
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:
shelljs@0.8.4, shelljs@^0.8.5:
version "0.8.5"
resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.5.tgz#de055408d8361bed66c669d2f000538ced8ee20c"
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"
integrity sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==
trim-newlines@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613"
integrity sha512-Nm4cF79FhSTzrLKGDMi3I4utBtFv8qKy4sq1enftf2gMdpqI8oVQTAfySkTz5r49giVzDj88SVZXP4CeYQwjaw==
trim-newlines@^1.0.0, trim-newlines@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.1.tgz#260a5d962d8b752425b32f3a7db0dcacd176c144"
integrity sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==
trim-trailing-lines@^1.0.0:
version "1.1.4"
@ -13651,10 +13635,10 @@ typedarray@^0.0.6:
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==
typescript@4.7.4:
version "4.7.4"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235"
integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==
typescript@4.8.2:
version "4.8.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.2.tgz#e3b33d5ccfb5914e4eeab6699cf208adee3fd790"
integrity sha512-C0I1UsrrDHo2fYI5oaCGbSejwX4ch+9Y5jTQELvovfmFkK3HHSZJB8MSJcWLmCUBzQBchCrZ9rMRV6GuNrvGtw==
ua-parser-js@^0.7.30:
version "0.7.31"

107
yarn.lock
View File

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