diff --git a/frontend/src/component/admin/auth/AuthSettings.tsx b/frontend/src/component/admin/auth/AuthSettings.tsx
index 59856f3bbda2..59df7bfd3751 100644
--- a/frontend/src/component/admin/auth/AuthSettings.tsx
+++ b/frontend/src/component/admin/auth/AuthSettings.tsx
@@ -1,4 +1,4 @@
-import { Alert, Tab, Tabs } from '@mui/material';
+import { Tab, Tabs } from '@mui/material';
import { PageContent } from 'component/common/PageContent/PageContent';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
@@ -15,7 +15,6 @@ import { TabPanel } from 'component/common/TabNav/TabPanel/TabPanel';
import { usePageTitle } from 'hooks/usePageTitle';
export const AuthSettings = () => {
- const { authenticationType } = useUiConfig().uiConfig;
const { uiConfig, isEnterprise } = useUiConfig();
const tabs = [
@@ -35,17 +34,14 @@ export const AuthSettings = () => {
label: 'Google',
component: ,
},
+ {
+ label: 'SCIM',
+ component: ,
+ },
].filter(
(item) => uiConfig.flags?.googleAuthEnabled || item.label !== 'Google',
);
- if (isEnterprise()) {
- tabs.push({
- label: 'SCIM',
- component: ,
- });
- }
-
const [activeTab, setActiveTab] = useState(0);
usePageTitle(`Single sign-on: ${tabs[activeTab].label}`);
@@ -56,7 +52,7 @@ export const AuthSettings = () => {
withTabs
header={
{
}
>
}
- />
-
- You are running Unleash in demo mode. You have
- to use the Enterprise edition in order configure
- Single Sign-on.
-
- }
- />
-
- You have decided to use custom authentication
- type. You have to use the Enterprise edition in
- order configure Single Sign-on from the user
- interface.
-
- }
- />
-
- Your Unleash instance is managed by the Unleash
- team.
-
- }
- />
-
{tabs.map((tab, index) => (
@@ -133,6 +95,7 @@ export const AuthSettings = () => {
))}
}
+ elseShow={}
/>
diff --git a/frontend/src/component/admin/cors/CorsForm.tsx b/frontend/src/component/admin/cors/CorsForm.tsx
index c3885e8821c5..0f8e2d7ca4a3 100644
--- a/frontend/src/component/admin/cors/CorsForm.tsx
+++ b/frontend/src/component/admin/cors/CorsForm.tsx
@@ -1,4 +1,3 @@
-import { ADMIN } from 'component/providers/AccessProvider/permissions';
import type React from 'react';
import { useState } from 'react';
import { TextField, Box } from '@mui/material';
@@ -7,23 +6,30 @@ import { useUiConfigApi } from 'hooks/api/actions/useUiConfigApi/useUiConfigApi'
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useId } from 'hooks/useId';
+import { ADMIN, UPDATE_CORS } from '@server/types/permissions';
+import { useUiFlag } from 'hooks/useUiFlag';
interface ICorsFormProps {
frontendApiOrigins: string[] | undefined;
}
export const CorsForm = ({ frontendApiOrigins }: ICorsFormProps) => {
- const { setFrontendSettings } = useUiConfigApi();
+ const { setFrontendSettings, setCors } = useUiConfigApi();
const { setToastData, setToastApiError } = useToast();
const [value, setValue] = useState(formatInputValue(frontendApiOrigins));
const inputFieldId = useId();
const helpTextId = useId();
+ const isGranularPermissionsEnabled = useUiFlag('granularAdminPermissions');
const onSubmit = async (event: React.FormEvent) => {
try {
const split = parseInputValue(value);
event.preventDefault();
- await setFrontendSettings(split);
+ if (isGranularPermissionsEnabled) {
+ await setCors(split);
+ } else {
+ await setFrontendSettings(split);
+ }
setValue(formatInputValue(split));
setToastData({ text: 'Settings saved', type: 'success' });
} catch (error) {
@@ -67,7 +73,7 @@ export const CorsForm = ({ frontendApiOrigins }: ICorsFormProps) => {
style: { fontFamily: 'monospace', fontSize: '0.8em' },
}}
/>
-
+
);
diff --git a/frontend/src/component/admin/cors/index.tsx b/frontend/src/component/admin/cors/index.tsx
index ceda3f629c8b..1f84bfa1dd42 100644
--- a/frontend/src/component/admin/cors/index.tsx
+++ b/frontend/src/component/admin/cors/index.tsx
@@ -1,15 +1,15 @@
import { PermissionGuard } from 'component/common/PermissionGuard/PermissionGuard';
-import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { Box } from '@mui/material';
import { CorsHelpAlert } from 'component/admin/cors/CorsHelpAlert';
import { CorsForm } from 'component/admin/cors/CorsForm';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+import { ADMIN, UPDATE_CORS } from '@server/types/permissions';
export const CorsAdmin = () => (
diff --git a/frontend/src/component/admin/groups/GroupsList/GroupsList.tsx b/frontend/src/component/admin/groups/GroupsList/GroupsList.tsx
index d15098522c0b..0b1d66cd0d52 100644
--- a/frontend/src/component/admin/groups/GroupsList/GroupsList.tsx
+++ b/frontend/src/component/admin/groups/GroupsList/GroupsList.tsx
@@ -23,6 +23,7 @@ const StyledGridContainer = styled('div')(({ theme }) => ({
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: theme.spacing(2),
+ gridAutoRows: '1fr',
}));
type PageQueryType = Partial>;
diff --git a/frontend/src/component/admin/users/UsersHeader/UsersHeader.tsx b/frontend/src/component/admin/users/UsersHeader/UsersHeader.tsx
index 43b6edb6233f..61f984d09e7f 100644
--- a/frontend/src/component/admin/users/UsersHeader/UsersHeader.tsx
+++ b/frontend/src/component/admin/users/UsersHeader/UsersHeader.tsx
@@ -1,6 +1,5 @@
import { Box, styled } from '@mui/material';
import { InviteLinkBar } from '../InviteLinkBar/InviteLinkBar';
-import { useUiFlag } from 'hooks/useUiFlag';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { LicensedUsersBox } from './LicensedUsersBox';
@@ -24,9 +23,8 @@ const StyledElement = styled(Box)(({ theme }) => ({
}));
export const UsersHeader = () => {
- const licensedUsers = useUiFlag('licensedUsers');
const { isOss } = useUiConfig();
- const licensedUsersEnabled = licensedUsers && !isOss();
+ const licensedUsersEnabled = !isOss();
return (
diff --git a/frontend/src/component/events/EventPage/EventPage.tsx b/frontend/src/component/events/EventPage/EventPage.tsx
index a5155a309bcc..ad5f572c7648 100644
--- a/frontend/src/component/events/EventPage/EventPage.tsx
+++ b/frontend/src/component/events/EventPage/EventPage.tsx
@@ -1,9 +1,9 @@
-import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { PermissionGuard } from 'component/common/PermissionGuard/PermissionGuard';
import { EventLog } from 'component/events/EventLog/EventLog';
+import { READ_LOGS, ADMIN } from '@server/types/permissions';
export const EventPage = () => (
-
+
);
diff --git a/frontend/src/component/loginHistory/LoginHistory.tsx b/frontend/src/component/loginHistory/LoginHistory.tsx
index bbd8b4674a2b..319cd92055d7 100644
--- a/frontend/src/component/loginHistory/LoginHistory.tsx
+++ b/frontend/src/component/loginHistory/LoginHistory.tsx
@@ -3,6 +3,7 @@ import { PermissionGuard } from 'component/common/PermissionGuard/PermissionGuar
import { LoginHistoryTable } from './LoginHistoryTable/LoginHistoryTable';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { PremiumFeature } from 'component/common/PremiumFeature/PremiumFeature';
+import { READ_LOGS } from '@server/types/permissions';
export const LoginHistory = () => {
const { isEnterprise } = useUiConfig();
@@ -13,7 +14,7 @@ export const LoginHistory = () => {
return (
diff --git a/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm/MilestoneList/MilestoneCard/MilestoneCard.tsx b/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm/MilestoneList/MilestoneCard/MilestoneCard.tsx
index 10e115c6df92..87a269f77de0 100644
--- a/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm/MilestoneList/MilestoneCard/MilestoneCard.tsx
+++ b/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm/MilestoneList/MilestoneCard/MilestoneCard.tsx
@@ -71,9 +71,14 @@ const StyledAccordion = styled(Accordion)(({ theme }) => ({
const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({
boxShadow: 'none',
padding: theme.spacing(1.5, 2),
+ borderRadius: theme.shape.borderRadiusMedium,
[theme.breakpoints.down(400)]: {
padding: theme.spacing(1, 2),
},
+ '&.Mui-focusVisible': {
+ backgroundColor: theme.palette.background.paper,
+ padding: theme.spacing(0.5, 2, 0.3, 2),
+ },
}));
const StyledAccordionDetails = styled(AccordionDetails)(({ theme }) => ({
diff --git a/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm/MilestoneList/MilestoneCard/MilestoneCardName.tsx b/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm/MilestoneList/MilestoneCard/MilestoneCardName.tsx
index 602bd8969383..d348f1485759 100644
--- a/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm/MilestoneList/MilestoneCard/MilestoneCardName.tsx
+++ b/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm/MilestoneList/MilestoneCard/MilestoneCardName.tsx
@@ -57,6 +57,10 @@ export const MilestoneCardName = ({
setEditMode(false);
}
}}
+ onClick={(ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ }}
/>
)}
{!editMode && (
diff --git a/frontend/src/hooks/api/actions/useUiConfigApi/useUiConfigApi.ts b/frontend/src/hooks/api/actions/useUiConfigApi/useUiConfigApi.ts
index 9853704beff7..d80c89761fbd 100644
--- a/frontend/src/hooks/api/actions/useUiConfigApi/useUiConfigApi.ts
+++ b/frontend/src/hooks/api/actions/useUiConfigApi/useUiConfigApi.ts
@@ -5,6 +5,9 @@ export const useUiConfigApi = () => {
propagateErrors: true,
});
+ /**
+ * @deprecated remove when `granularAdminPermissions` flag is removed
+ */
const setFrontendSettings = async (
frontendApiOrigins: string[],
): Promise => {
@@ -19,8 +22,18 @@ export const useUiConfigApi = () => {
await makeRequest(req.caller, req.id);
};
+ const setCors = async (frontendApiOrigins: string[]): Promise => {
+ const req = createRequest(
+ 'api/admin/ui-config/cors',
+ { method: 'POST', body: JSON.stringify({ frontendApiOrigins }) },
+ 'setCors',
+ );
+ await makeRequest(req.caller, req.id);
+ };
+
return {
setFrontendSettings,
+ setCors,
loading,
errors,
};
diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts
index 8c15358e56f9..792d7aef1c2d 100644
--- a/frontend/src/interfaces/uiConfig.ts
+++ b/frontend/src/interfaces/uiConfig.ts
@@ -89,7 +89,6 @@ export type UiFlags = {
productivityReportEmail?: boolean;
showUserDeviceCount?: boolean;
flagOverviewRedesign?: boolean;
- licensedUsers?: boolean;
granularAdminPermissions?: boolean;
};
diff --git a/package.json b/package.json
index 237bb76cc171..ad39e457d898 100644
--- a/package.json
+++ b/package.json
@@ -167,7 +167,7 @@
"stoppable": "^1.1.0",
"ts-toolbelt": "^9.6.0",
"type-is": "^1.6.18",
- "unleash-client": "^6.3.3",
+ "unleash-client": "^6.4.0",
"uuid": "^9.0.0"
},
"devDependencies": {
diff --git a/src/lib/features/client-feature-toggles/client-feature-toggle-service.ts b/src/lib/features/client-feature-toggles/client-feature-toggle-service.ts
index a0d85e042ec2..cbcc77fd6f6d 100644
--- a/src/lib/features/client-feature-toggles/client-feature-toggle-service.ts
+++ b/src/lib/features/client-feature-toggles/client-feature-toggle-service.ts
@@ -9,10 +9,8 @@ import type {
import type { Logger } from '../../logger';
import type { FeatureConfigurationClient } from '../feature-toggle/types/feature-toggle-strategies-store-type';
-import type {
- RevisionDeltaEntry,
- ClientFeatureToggleDelta,
-} from './delta/client-feature-toggle-delta';
+import type { ClientFeatureToggleDelta } from './delta/client-feature-toggle-delta';
+import type { ClientFeaturesDeltaSchema } from '../../openapi';
export class ClientFeatureToggleService {
private logger: Logger;
@@ -44,7 +42,7 @@ export class ClientFeatureToggleService {
async getClientDelta(
revisionId: number | undefined,
query: IFeatureToggleQuery,
- ): Promise {
+ ): Promise {
if (this.clientFeatureToggleDelta !== null) {
return this.clientFeatureToggleDelta.getDelta(revisionId, query);
} else {
diff --git a/src/lib/features/client-feature-toggles/tests/client-feature-delta-api.e2e.test.ts b/src/lib/features/client-feature-toggles/delta/client-feature-delta-api.e2e.test.ts
similarity index 80%
rename from src/lib/features/client-feature-toggles/tests/client-feature-delta-api.e2e.test.ts
rename to src/lib/features/client-feature-toggles/delta/client-feature-delta-api.e2e.test.ts
index ed729de825a0..fd540ba4185f 100644
--- a/src/lib/features/client-feature-toggles/tests/client-feature-delta-api.e2e.test.ts
+++ b/src/lib/features/client-feature-toggles/delta/client-feature-delta-api.e2e.test.ts
@@ -140,3 +140,37 @@ const syncRevisions = async () => {
// @ts-ignore
await app.services.clientFeatureToggleService.clientFeatureToggleDelta.onUpdateRevisionEvent();
};
+
+test('archived features should not be returned as updated', async () => {
+ await app.createFeature('base_feature');
+ await syncRevisions();
+ const { body } = await app.request.get('/api/client/delta').expect(200);
+ const currentRevisionId = body.revisionId;
+
+ expect(body).toMatchObject({
+ updated: [
+ {
+ name: 'base_feature',
+ },
+ ],
+ });
+
+ await app.archiveFeature('base_feature');
+ await app.createFeature('new_feature');
+
+ await syncRevisions();
+
+ const { body: deltaBody } = await app.request
+ .get('/api/client/delta')
+ .set('If-None-Match', currentRevisionId)
+ .expect(200);
+
+ expect(deltaBody).toMatchObject({
+ updated: [
+ {
+ name: 'new_feature',
+ },
+ ],
+ removed: ['base_feature'],
+ });
+});
diff --git a/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta-controller.ts b/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta-controller.ts
index afa0769be76e..4c153a773ce0 100644
--- a/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta-controller.ts
+++ b/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta-controller.ts
@@ -17,8 +17,10 @@ import type { OpenApiService } from '../../../services/openapi-service';
import { NONE } from '../../../types/permissions';
import { createResponseSchema } from '../../../openapi/util/create-response-schema';
import type { ClientFeatureToggleService } from '../client-feature-toggle-service';
-import type { RevisionDeltaEntry } from './client-feature-toggle-delta';
-import { clientFeaturesDeltaSchema } from '../../../openapi';
+import {
+ type ClientFeaturesDeltaSchema,
+ clientFeaturesDeltaSchema,
+} from '../../../openapi';
import type { QueryOverride } from '../client-feature-toggle.controller';
export default class ClientFeatureToggleDeltaController extends Controller {
@@ -75,7 +77,7 @@ export default class ClientFeatureToggleDeltaController extends Controller {
async getDelta(
req: IAuthRequest,
- res: Response,
+ res: Response,
): Promise {
if (!this.flagResolver.isEnabled('deltaApi')) {
throw new NotFoundError();
diff --git a/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta-read-model-type.ts b/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta-read-model-type.ts
index 3b82bd508faa..cf0ec977c898 100644
--- a/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta-read-model-type.ts
+++ b/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta-read-model-type.ts
@@ -4,7 +4,7 @@ import type { FeatureConfigurationClient } from '../../feature-toggle/types/feat
export interface FeatureConfigurationDeltaClient
extends FeatureConfigurationClient {
description: string;
- impressionData: false;
+ impressionData: boolean;
}
export interface IClientFeatureToggleDeltaReadModel {
diff --git a/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta.ts b/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta.ts
index 79e04578a252..38234ea1df25 100644
--- a/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta.ts
+++ b/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta.ts
@@ -17,6 +17,7 @@ import type {
import { CLIENT_DELTA_MEMORY } from '../../../metric-events';
import type EventEmitter from 'events';
import type { Logger } from '../../../logger';
+import type { ClientFeaturesDeltaSchema } from '../../../openapi';
type DeletedFeature = {
name: string;
@@ -79,6 +80,7 @@ const filterRevisionByProject = (
(feature) =>
projects.includes('*') || projects.includes(feature.project),
);
+
return { ...revision, updated, removed };
};
@@ -153,7 +155,7 @@ export class ClientFeatureToggleDelta {
async getDelta(
sdkRevisionId: number | undefined,
query: IFeatureToggleQuery,
- ): Promise {
+ ): Promise {
const projects = query.project ? query.project : ['*'];
const environment = query.environment ? query.environment : 'default';
// TODO: filter by tags, what is namePrefix? anything else?
@@ -181,9 +183,10 @@ export class ClientFeatureToggleDelta {
projects,
);
- const revisionResponse = {
+ const revisionResponse: ClientFeaturesDeltaSchema = {
...compressedRevision,
segments: this.segments,
+ removed: compressedRevision.removed.map((feature) => feature.name),
};
return Promise.resolve(revisionResponse);
@@ -197,6 +200,9 @@ export class ClientFeatureToggleDelta {
}
}
+ /**
+ * This is used in client-feature-delta-api.e2e.test.ts, do not remove
+ */
public resetDelta() {
this.delta = {};
}
@@ -217,6 +223,7 @@ export class ClientFeatureToggleDelta {
...new Set(
changeEvents
.filter((event) => event.featureName)
+ .filter((event) => event.type !== 'feature-archived')
.map((event) => event.featureName!),
),
];
diff --git a/src/lib/features/context/context.ts b/src/lib/features/context/context.ts
index ca21648ac26f..46bfcf6e232f 100644
--- a/src/lib/features/context/context.ts
+++ b/src/lib/features/context/context.ts
@@ -40,6 +40,7 @@ import type { UpdateContextFieldSchema } from '../../openapi/spec/update-context
import type { CreateContextFieldSchema } from '../../openapi/spec/create-context-field-schema';
import { extractUserIdFromUser } from '../../util';
import type { LegalValueSchema } from '../../openapi';
+import type { WithTransactional } from '../../db/transaction';
interface ContextParam {
contextField: string;
@@ -50,7 +51,7 @@ interface DeleteLegalValueParam extends ContextParam {
}
export class ContextController extends Controller {
- private contextService: ContextService;
+ private transactionalContextService: WithTransactional;
private openApiService: OpenApiService;
@@ -59,14 +60,17 @@ export class ContextController extends Controller {
constructor(
config: IUnleashConfig,
{
- contextService,
+ transactionalContextService,
openApiService,
- }: Pick,
+ }: Pick<
+ IUnleashServices,
+ 'transactionalContextService' | 'openApiService'
+ >,
) {
super(config);
this.openApiService = openApiService;
this.logger = config.getLogger('/admin-api/context.ts');
- this.contextService = contextService;
+ this.transactionalContextService = transactionalContextService;
this.route({
method: 'get',
@@ -257,7 +261,9 @@ export class ContextController extends Controller {
res: Response,
): Promise {
res.status(200)
- .json(serializeDates(await this.contextService.getAll()))
+ .json(
+ serializeDates(await this.transactionalContextService.getAll()),
+ )
.end();
}
@@ -268,7 +274,7 @@ export class ContextController extends Controller {
try {
const name = req.params.contextField;
const contextField =
- await this.contextService.getContextField(name);
+ await this.transactionalContextService.getContextField(name);
this.openApiService.respondWithValidation(
200,
res,
@@ -286,9 +292,8 @@ export class ContextController extends Controller {
): Promise {
const value = req.body;
- const result = await this.contextService.createContextField(
- value,
- req.audit,
+ const result = await this.transactionalContextService.transactional(
+ (service) => service.createContextField(value, req.audit),
);
this.openApiService.respondWithValidation(
@@ -307,9 +312,8 @@ export class ContextController extends Controller {
const name = req.params.contextField;
const contextField = req.body;
- await this.contextService.updateContextField(
- { ...contextField, name },
- req.audit,
+ await this.transactionalContextService.transactional((service) =>
+ service.updateContextField({ ...contextField, name }, req.audit),
);
res.status(200).end();
}
@@ -321,9 +325,8 @@ export class ContextController extends Controller {
const name = req.params.contextField;
const legalValue = req.body;
- await this.contextService.updateLegalValue(
- { name, legalValue },
- req.audit,
+ await this.transactionalContextService.transactional((service) =>
+ service.updateLegalValue({ name, legalValue }, req.audit),
);
res.status(200).end();
}
@@ -335,9 +338,8 @@ export class ContextController extends Controller {
const name = req.params.contextField;
const legalValue = req.params.legalValue;
- await this.contextService.deleteLegalValue(
- { name, legalValue },
- req.audit,
+ await this.transactionalContextService.transactional((service) =>
+ service.deleteLegalValue({ name, legalValue }, req.audit),
);
res.status(200).end();
}
@@ -348,7 +350,9 @@ export class ContextController extends Controller {
): Promise {
const name = req.params.contextField;
- await this.contextService.deleteContextField(name, req.audit);
+ await this.transactionalContextService.transactional((service) =>
+ service.deleteContextField(name, req.audit),
+ );
res.status(200).end();
}
@@ -358,7 +362,7 @@ export class ContextController extends Controller {
): Promise {
const { name } = req.body;
- await this.contextService.validateName(name);
+ await this.transactionalContextService.validateName(name);
res.status(200).end();
}
@@ -369,7 +373,7 @@ export class ContextController extends Controller {
const { contextField } = req.params;
const { user } = req;
const contextFields =
- await this.contextService.getStrategiesByContextField(
+ await this.transactionalContextService.getStrategiesByContextField(
contextField,
extractUserIdFromUser(user),
);
diff --git a/src/lib/features/feature-search/feature.search.e2e.test.ts b/src/lib/features/feature-search/feature.search.e2e.test.ts
index ea836622523d..ed7f3368f345 100644
--- a/src/lib/features/feature-search/feature.search.e2e.test.ts
+++ b/src/lib/features/feature-search/feature.search.e2e.test.ts
@@ -6,7 +6,12 @@ import {
} from '../../../test/e2e/helpers/test-helper';
import getLogger from '../../../test/fixtures/no-logger';
import type { FeatureSearchQueryParameters } from '../../openapi/spec/feature-search-query-parameters';
-import { DEFAULT_PROJECT, type IUnleashStores } from '../../types';
+import {
+ CREATE_FEATURE_STRATEGY,
+ DEFAULT_PROJECT,
+ type IUnleashStores,
+ UPDATE_FEATURE_ENVIRONMENT,
+} from '../../types';
import { DEFAULT_ENV } from '../../util';
let app: IUnleashTest;
@@ -29,7 +34,7 @@ beforeAll(async () => {
);
stores = db.stores;
- await app.request
+ const { body } = await app.request
.post(`/auth/demo/login`)
.send({
email: 'user@getunleash.io',
@@ -43,12 +48,30 @@ beforeAll(async () => {
await app.linkProjectToEnvironment('default', 'development');
+ await stores.accessStore.addPermissionsToRole(
+ body.rootRole,
+ [
+ { name: UPDATE_FEATURE_ENVIRONMENT },
+ { name: CREATE_FEATURE_STRATEGY },
+ ],
+ 'development',
+ );
+
await stores.environmentStore.create({
name: 'production',
type: 'production',
});
await app.linkProjectToEnvironment('default', 'production');
+
+ await stores.accessStore.addPermissionsToRole(
+ body.rootRole,
+ [
+ { name: UPDATE_FEATURE_ENVIRONMENT },
+ { name: CREATE_FEATURE_STRATEGY },
+ ],
+ 'production',
+ );
});
afterAll(async () => {
diff --git a/src/lib/features/frontend-api/frontend-api-service.ts b/src/lib/features/frontend-api/frontend-api-service.ts
index 81858f5b2f3b..b2dd9fd39f07 100644
--- a/src/lib/features/frontend-api/frontend-api-service.ts
+++ b/src/lib/features/frontend-api/frontend-api-service.ts
@@ -208,6 +208,23 @@ export class FrontendApiService {
);
}
+ async setFrontendCorsSettings(
+ value: FrontendSettings['frontendApiOrigins'],
+ auditUser: IAuditUser,
+ ): Promise {
+ const error = validateOrigins(value);
+ if (error) {
+ throw new BadDataError(error);
+ }
+ const settings = (await this.getFrontendSettings(false)) || {};
+ await this.services.settingService.insert(
+ frontendSettingsKey,
+ { ...settings, frontendApiOrigins: value },
+ auditUser,
+ false,
+ );
+ }
+
async fetchFrontendSettings(): Promise {
try {
this.cachedFrontendSettings =
diff --git a/src/lib/features/project/project-service.e2e.test.ts b/src/lib/features/project/project-service.e2e.test.ts
index c7e5cf9c44b8..16a46dda2fe9 100644
--- a/src/lib/features/project/project-service.e2e.test.ts
+++ b/src/lib/features/project/project-service.e2e.test.ts
@@ -805,6 +805,11 @@ describe('Managing Project access', () => {
mode: 'open' as const,
defaultStickiness: 'clientId',
};
+ await db.stores.environmentStore.create({
+ name: 'production',
+ type: 'production',
+ enabled: true,
+ });
const auditUser = extractAuditInfoFromUser(user);
await projectService.createProject(project, user, auditUser);
diff --git a/src/lib/openapi/spec/client-features-delta-schema.test.ts b/src/lib/openapi/spec/client-features-delta-schema.test.ts
new file mode 100644
index 000000000000..0510137771e6
--- /dev/null
+++ b/src/lib/openapi/spec/client-features-delta-schema.test.ts
@@ -0,0 +1,27 @@
+import { validateSchema } from '../validate';
+import type { ClientFeaturesDeltaSchema } from './client-features-delta-schema';
+
+test('clientFeaturesDeltaSchema all fields', () => {
+ const data: ClientFeaturesDeltaSchema = {
+ revisionId: 6,
+ updated: [
+ {
+ impressionData: false,
+ enabled: false,
+ name: 'base_feature',
+ description: null,
+ project: 'default',
+ stale: false,
+ type: 'release',
+ variants: [],
+ strategies: [],
+ },
+ ],
+ removed: [],
+ segments: [],
+ };
+
+ expect(
+ validateSchema('#/components/schemas/clientFeaturesDeltaSchema', data),
+ ).toBeUndefined();
+});
diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts
index 38b878c2c5ee..f22c5fa1acc4 100644
--- a/src/lib/openapi/spec/index.ts
+++ b/src/lib/openapi/spec/index.ts
@@ -177,6 +177,7 @@ export * from './search-features-schema';
export * from './segment-schema';
export * from './segment-strategies-schema';
export * from './segments-schema';
+export * from './set-cors-schema';
export * from './set-strategy-sort-order-schema';
export * from './set-ui-config-schema';
export * from './sort-order-schema';
diff --git a/src/lib/openapi/spec/set-cors-schema.ts b/src/lib/openapi/spec/set-cors-schema.ts
new file mode 100644
index 000000000000..c3f05a742102
--- /dev/null
+++ b/src/lib/openapi/spec/set-cors-schema.ts
@@ -0,0 +1,20 @@
+import type { FromSchema } from 'json-schema-to-ts';
+
+export const setCorsSchema = {
+ $id: '#/components/schemas/setCorsSchema',
+ type: 'object',
+ additionalProperties: false,
+ description: 'Unleash CORS configuration.',
+ properties: {
+ frontendApiOrigins: {
+ description:
+ 'The list of origins that the front-end API should accept requests from.',
+ example: ['*'],
+ type: 'array',
+ items: { type: 'string' },
+ },
+ },
+ components: {},
+} as const;
+
+export type SetCorsSchema = FromSchema;
diff --git a/src/lib/routes/admin-api/config.test.ts b/src/lib/routes/admin-api/config.test.ts
index 364712cf8eb1..e65da9941393 100644
--- a/src/lib/routes/admin-api/config.test.ts
+++ b/src/lib/routes/admin-api/config.test.ts
@@ -19,6 +19,11 @@ const uiConfig = {
async function getSetup() {
const base = `/random${Math.round(Math.random() * 1000)}`;
const config = createTestConfig({
+ experimental: {
+ flags: {
+ granularAdminPermissions: true,
+ },
+ },
server: { baseUriPath: base },
ui: uiConfig,
});
@@ -56,3 +61,26 @@ test('should get ui config', async () => {
expect(body.segmentValuesLimit).toEqual(DEFAULT_SEGMENT_VALUES_LIMIT);
expect(body.strategySegmentsLimit).toEqual(DEFAULT_STRATEGY_SEGMENTS_LIMIT);
});
+
+test('should update CORS settings', async () => {
+ const { body } = await request
+ .get(`${base}/api/admin/ui-config`)
+ .expect('Content-Type', /json/)
+ .expect(200);
+
+ expect(body.frontendApiOrigins).toEqual(['*']);
+
+ await request
+ .post(`${base}/api/admin/ui-config/cors`)
+ .send({
+ frontendApiOrigins: ['https://example.com'],
+ })
+ .expect(204);
+
+ const { body: updatedBody } = await request
+ .get(`${base}/api/admin/ui-config`)
+ .expect('Content-Type', /json/)
+ .expect(200);
+
+ expect(updatedBody.frontendApiOrigins).toEqual(['https://example.com']);
+});
diff --git a/src/lib/routes/admin-api/config.ts b/src/lib/routes/admin-api/config.ts
index ec19b96e86ce..93f1d32976c3 100644
--- a/src/lib/routes/admin-api/config.ts
+++ b/src/lib/routes/admin-api/config.ts
@@ -10,7 +10,7 @@ import {
type SimpleAuthSettings,
simpleAuthSettingsKey,
} from '../../types/settings/simple-auth-settings';
-import { ADMIN, NONE } from '../../types/permissions';
+import { ADMIN, NONE, UPDATE_CORS } from '../../types/permissions';
import { createResponseSchema } from '../../openapi/util/create-response-schema';
import {
uiConfigSchema,
@@ -22,6 +22,7 @@ import { emptyResponse } from '../../openapi/util/standard-responses';
import type { IAuthRequest } from '../unleash-types';
import NotFoundError from '../../error/notfound-error';
import type { SetUiConfigSchema } from '../../openapi/spec/set-ui-config-schema';
+import type { SetCorsSchema } from '../../openapi/spec/set-cors-schema';
import { createRequestSchema } from '../../openapi/util/create-request-schema';
import type { FrontendApiService, SessionService } from '../../services';
import type MaintenanceService from '../../features/maintenance/maintenance-service';
@@ -99,6 +100,7 @@ class ConfigController extends Controller {
],
});
+ // TODO: deprecate when removing `granularAdminPermissions` flag
this.route({
method: 'post',
path: '',
@@ -116,6 +118,24 @@ class ConfigController extends Controller {
}),
],
});
+
+ this.route({
+ method: 'post',
+ path: '/cors',
+ handler: this.setCors,
+ permission: [ADMIN, UPDATE_CORS],
+ middleware: [
+ openApiService.validPath({
+ tags: ['Admin UI'],
+ summary: 'Sets allowed CORS origins',
+ description:
+ 'Sets Cross-Origin Resource Sharing headers for Frontend SDK API.',
+ operationId: 'setCors',
+ requestBody: createRequestSchema('setCorsSchema'),
+ responses: { 204: emptyResponse },
+ }),
+ ],
+ });
}
async getUiConfig(
@@ -197,6 +217,30 @@ class ConfigController extends Controller {
throw new NotFoundError();
}
+
+ async setCors(
+ req: IAuthRequest,
+ res: Response,
+ ): Promise {
+ const granularAdminPermissions = this.flagResolver.isEnabled(
+ 'granularAdminPermissions',
+ );
+
+ if (!granularAdminPermissions) {
+ throw new NotFoundError();
+ }
+
+ if (req.body.frontendApiOrigins) {
+ await this.frontendApiService.setFrontendCorsSettings(
+ req.body.frontendApiOrigins,
+ req.audit,
+ );
+ res.sendStatus(204);
+ return;
+ }
+
+ throw new NotFoundError();
+ }
}
export default ConfigController;
diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts
index f40705d2479d..3cdc3fe1ba02 100644
--- a/src/lib/services/index.ts
+++ b/src/lib/services/index.ts
@@ -200,9 +200,10 @@ export const createServices = (
? new FeatureLifecycleReadModel(db, config.flagResolver)
: new FakeFeatureLifecycleReadModel();
- const contextService = db
+ const transactionalContextService = db
? withTransactional(createContextService(config), db)
: withFakeTransactional(createFakeContextService(config));
+ const contextService = transactionalContextService;
const emailService = new EmailService(config);
const featureTypeService = new FeatureTypeService(
stores,
@@ -434,6 +435,7 @@ export const createServices = (
clientInstanceService,
clientMetricsServiceV2,
contextService,
+ transactionalContextService,
versionService,
apiTokenService,
emailService,
diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts
index a6248820f56c..5e0f22052a14 100644
--- a/src/lib/types/experimental.ts
+++ b/src/lib/types/experimental.ts
@@ -56,12 +56,12 @@ export type IFlagKey =
| 'showUserDeviceCount'
| 'deleteStaleUserSessions'
| 'memorizeStats'
- | 'licensedUsers'
| 'granularAdminPermissions'
| 'streaming'
| 'etagVariant'
| 'oidcRedirect'
- | 'deltaApi';
+ | 'deltaApi'
+ | 'newHostedAuthHandler';
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
@@ -265,10 +265,6 @@ const flags: IFlags = {
process.env.UNLEASH_EXPERIMENTAL_FLAG_OVERVIEW_REDESIGN,
false,
),
- licensedUsers: parseEnvVarBoolean(
- process.env.UNLEASH_EXPERIMENTAL_FLAG_LICENSED_USERS,
- false,
- ),
granularAdminPermissions: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_GRANULAR_ADMIN_PERMISSIONS,
false,
@@ -290,6 +286,10 @@ const flags: IFlags = {
process.env.UNLEASH_EXPERIMENTAL_DELTA_API,
false,
),
+ newHostedAuthHandler: parseEnvVarBoolean(
+ process.env.UNLEASH_EXPERIMENTAL_NEW_HOSTED_AUTH_HANDLER,
+ false,
+ ),
};
export const defaultExperimentalOptions: IExperimentalOptions = {
diff --git a/src/lib/types/option.ts b/src/lib/types/option.ts
index b4919ad34152..98cd76f8cc8f 100644
--- a/src/lib/types/option.ts
+++ b/src/lib/types/option.ts
@@ -54,7 +54,13 @@ export interface IVersionOption {
export enum IAuthType {
OPEN_SOURCE = 'open-source',
DEMO = 'demo',
+ /**
+ * Self-hosted by the customer. Should eventually be renamed to better reflect this.
+ */
ENTERPRISE = 'enterprise',
+ /**
+ * Hosted by Unleash.
+ */
HOSTED = 'hosted',
CUSTOM = 'custom',
NONE = 'none',
diff --git a/src/lib/types/permissions.ts b/src/lib/types/permissions.ts
index d179e93f8833..bbf07539eaa3 100644
--- a/src/lib/types/permissions.ts
+++ b/src/lib/types/permissions.ts
@@ -41,8 +41,10 @@ export const CREATE_TAG_TYPE = 'CREATE_TAG_TYPE';
export const UPDATE_TAG_TYPE = 'UPDATE_TAG_TYPE';
export const DELETE_TAG_TYPE = 'DELETE_TAG_TYPE';
+export const READ_LOGS = 'READ_LOGS';
export const UPDATE_MAINTENANCE_MODE = 'UPDATE_MAINTENANCE_MODE';
export const UPDATE_INSTANCE_BANNERS = 'UPDATE_INSTANCE_BANNERS';
+export const UPDATE_CORS = 'UPDATE_CORS';
export const UPDATE_AUTH_CONFIGURATION = 'UPDATE_AUTH_CONFIGURATION';
// Project
@@ -147,7 +149,12 @@ export const ROOT_PERMISSION_CATEGORIES = [
},
{
label: 'Instance maintenance',
- permissions: [UPDATE_MAINTENANCE_MODE, UPDATE_INSTANCE_BANNERS],
+ permissions: [
+ READ_LOGS,
+ UPDATE_MAINTENANCE_MODE,
+ UPDATE_INSTANCE_BANNERS,
+ UPDATE_CORS,
+ ],
},
{
label: 'Authentication',
@@ -162,4 +169,5 @@ export const MAINTENANCE_MODE_PERMISSIONS = [
READ_CLIENT_API_TOKEN,
READ_FRONTEND_API_TOKEN,
UPDATE_MAINTENANCE_MODE,
+ READ_LOGS,
];
diff --git a/src/lib/types/services.ts b/src/lib/types/services.ts
index 711bd4abf6ec..5fa6d107c7fc 100644
--- a/src/lib/types/services.ts
+++ b/src/lib/types/services.ts
@@ -69,6 +69,7 @@ export interface IUnleashServices {
clientInstanceService: ClientInstanceService;
clientMetricsServiceV2: ClientMetricsServiceV2;
contextService: ContextService;
+ transactionalContextService: WithTransactional;
emailService: EmailService;
environmentService: EnvironmentService;
transactionalEnvironmentService: WithTransactional;
diff --git a/src/migrations/20241129102205-add-foreign-key-to-role-permissions.js b/src/migrations/20241129102205-add-foreign-key-to-role-permissions.js
new file mode 100644
index 000000000000..f2e44bc69f3c
--- /dev/null
+++ b/src/migrations/20241129102205-add-foreign-key-to-role-permissions.js
@@ -0,0 +1,20 @@
+exports.up = function (db, cb) {
+ db.runSql(
+ `
+ UPDATE role_permission SET environment = null where environment = '';
+ DELETE FROM role_permission WHERE environment IS NOT NULL AND environment NOT IN (SELECT name FROM environments);
+ ALTER TABLE role_permission ADD CONSTRAINT fk_role_permission_environment FOREIGN KEY (environment) REFERENCES environments(name) ON DELETE CASCADE;
+ `,
+ cb,
+ );
+};
+
+exports.down = function (db, cb) {
+ db.runSql(
+ `
+ ALTER TABLE role_permission
+ DROP CONSTRAINT IF EXISTS fk_role_permission_environment;
+ `,
+ cb,
+ );
+};
diff --git a/src/migrations/20250102150900-add-permission-read-logs.js b/src/migrations/20250102150900-add-permission-read-logs.js
new file mode 100644
index 000000000000..e42096a8a063
--- /dev/null
+++ b/src/migrations/20250102150900-add-permission-read-logs.js
@@ -0,0 +1,13 @@
+'use strict';
+
+exports.up = function (db, cb) {
+ db.runSql(`
+ INSERT INTO permissions (permission, display_name, type) VALUES ('READ_LOGS', 'Read instance logs and login history', 'root');
+ `, cb);
+}
+
+exports.down = function (db, cb) {
+ db.runSql(`
+ DELETE FROM permissions WHERE permission IN ('READ_LOGS');
+ `, cb);
+}
diff --git a/src/server-dev.ts b/src/server-dev.ts
index ef98f3895fa4..22aea88451de 100644
--- a/src/server-dev.ts
+++ b/src/server-dev.ts
@@ -53,7 +53,6 @@ process.nextTick(async () => {
releasePlans: false,
showUserDeviceCount: true,
flagOverviewRedesign: false,
- licensedUsers: true,
granularAdminPermissions: true,
deltaApi: true,
},
diff --git a/src/test/e2e/helpers/database-init.ts b/src/test/e2e/helpers/database-init.ts
index 8ce1e28482bc..5182282d741d 100644
--- a/src/test/e2e/helpers/database-init.ts
+++ b/src/test/e2e/helpers/database-init.ts
@@ -21,9 +21,19 @@ delete process.env.DATABASE_URL;
// because of db-migrate bug (https://github.com/Unleash/unleash/issues/171)
process.setMaxListeners(0);
+async function getDefaultEnvRolePermissions(knex) {
+ return knex.table('role_permission').whereIn('environment', ['default']);
+}
+
+async function restoreRolePermissions(knex, rolePermissions) {
+ await knex.table('role_permission').insert(rolePermissions);
+}
+
async function resetDatabase(knex) {
return Promise.all([
- knex.table('environments').del(),
+ knex
+ .table('environments')
+ .del(), // deletes role permissions transitively
knex.table('strategies').del(),
knex.table('features').del(),
knex.table('client_applications').del(),
@@ -110,15 +120,20 @@ export default async function init(
const testDb = createDb(config);
const stores = await createStores(config, testDb);
stores.eventStore.setMaxListeners(0);
+ const defaultRolePermissions = await getDefaultEnvRolePermissions(testDb);
await resetDatabase(testDb);
await setupDatabase(stores);
+ await restoreRolePermissions(testDb, defaultRolePermissions);
return {
rawDatabase: testDb,
stores,
reset: async () => {
+ const defaultRolePermissions =
+ await getDefaultEnvRolePermissions(testDb);
await resetDatabase(testDb);
await setupDatabase(stores);
+ await restoreRolePermissions(testDb, defaultRolePermissions);
},
destroy: async () => {
return new Promise((resolve, reject) => {
diff --git a/src/test/e2e/services/access-service.e2e.test.ts b/src/test/e2e/services/access-service.e2e.test.ts
index 8f2cbd8c317f..75fe15e356e9 100644
--- a/src/test/e2e/services/access-service.e2e.test.ts
+++ b/src/test/e2e/services/access-service.e2e.test.ts
@@ -94,8 +94,6 @@ const createRole = async (rolePermissions: PermissionRef[]) => {
const hasCommonProjectAccess = async (user, projectName, condition) => {
const defaultEnv = 'default';
- const developmentEnv = 'development';
- const productionEnv = 'production';
const {
CREATE_FEATURE,
@@ -155,70 +153,6 @@ const hasCommonProjectAccess = async (user, projectName, condition) => {
defaultEnv,
),
).toBe(condition);
- expect(
- await accessService.hasPermission(
- user,
- CREATE_FEATURE_STRATEGY,
- projectName,
- developmentEnv,
- ),
- ).toBe(condition);
- expect(
- await accessService.hasPermission(
- user,
- UPDATE_FEATURE_STRATEGY,
- projectName,
- developmentEnv,
- ),
- ).toBe(condition);
- expect(
- await accessService.hasPermission(
- user,
- DELETE_FEATURE_STRATEGY,
- projectName,
- developmentEnv,
- ),
- ).toBe(condition);
- expect(
- await accessService.hasPermission(
- user,
- UPDATE_FEATURE_ENVIRONMENT,
- projectName,
- developmentEnv,
- ),
- ).toBe(condition);
- expect(
- await accessService.hasPermission(
- user,
- CREATE_FEATURE_STRATEGY,
- projectName,
- productionEnv,
- ),
- ).toBe(condition);
- expect(
- await accessService.hasPermission(
- user,
- UPDATE_FEATURE_STRATEGY,
- projectName,
- productionEnv,
- ),
- ).toBe(condition);
- expect(
- await accessService.hasPermission(
- user,
- DELETE_FEATURE_STRATEGY,
- projectName,
- productionEnv,
- ),
- ).toBe(condition);
- expect(
- await accessService.hasPermission(
- user,
- UPDATE_FEATURE_ENVIRONMENT,
- projectName,
- productionEnv,
- ),
- ).toBe(condition);
};
const hasFullProjectAccess = async (user, projectName: string, condition) => {
@@ -378,7 +312,7 @@ test('should remove CREATE_FEATURE on default environment', async () => {
await accessService.addPermissionToRole(
editRole.id,
permissions.CREATE_FEATURE,
- '*',
+ 'default',
);
// TODO: to validate the remove works, we should make sure that we had permission before removing it
@@ -637,7 +571,7 @@ test('should support permission with "ALL" environment requirement', async () =>
await accessStore.addPermissionsToRole(
customRole.id,
[{ name: CREATE_FEATURE_STRATEGY }],
- 'production',
+ 'default',
);
await accessStore.addUserToRole(user.id, customRole.id, ALL_PROJECTS);
@@ -645,7 +579,7 @@ test('should support permission with "ALL" environment requirement', async () =>
user,
CREATE_FEATURE_STRATEGY,
'default',
- 'production',
+ 'default',
);
expect(hasAccess).toBe(true);
@@ -667,7 +601,7 @@ test('Should have access to create a strategy in an environment', async () => {
user,
CREATE_FEATURE_STRATEGY,
'default',
- 'development',
+ 'default',
),
).toBe(true);
});
@@ -693,7 +627,7 @@ test('Should have access to edit a strategy in an environment', async () => {
user,
UPDATE_FEATURE_STRATEGY,
'default',
- 'development',
+ 'default',
),
).toBe(true);
});
@@ -706,7 +640,7 @@ test('Should have access to delete a strategy in an environment', async () => {
user,
DELETE_FEATURE_STRATEGY,
'default',
- 'development',
+ 'default',
),
).toBe(true);
});
diff --git a/yarn.lock b/yarn.lock
index 54192fc856c3..014f65b439c9 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -9229,9 +9229,9 @@ __metadata:
languageName: node
linkType: hard
-"unleash-client@npm:^6.3.3":
- version: 6.3.3
- resolution: "unleash-client@npm:6.3.3"
+"unleash-client@npm:^6.4.0":
+ version: 6.4.0
+ resolution: "unleash-client@npm:6.4.0"
dependencies:
http-proxy-agent: "npm:^7.0.2"
https-proxy-agent: "npm:^7.0.5"
@@ -9241,7 +9241,7 @@ __metadata:
murmurhash3js: "npm:^3.0.1"
proxy-from-env: "npm:^1.1.0"
semver: "npm:^7.6.2"
- checksum: 10c0/047f3b63aa1cadde15abc39a4f627f77c297aaa15d11928d93d86815f88b7d72811c931be4a7fcd44887cc12bdb5ad32f674dbe19b62665ece4a86dac77224bb
+ checksum: 10c0/df9647b903d21537f1d4d1fbebc4bd1451e8e1f6090f17bdab1eba67158bd98794e535c7850be1472f839cd3f2b06398add097fc7a1a8c5c1ec936c6c0274427
languageName: node
linkType: hard
@@ -9356,7 +9356,7 @@ __metadata:
tsc-watch: "npm:6.2.1"
type-is: "npm:^1.6.18"
typescript: "npm:5.4.5"
- unleash-client: "npm:^6.3.3"
+ unleash-client: "npm:^6.4.0"
uuid: "npm:^9.0.0"
wait-on: "npm:^7.2.0"
languageName: unknown