0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

feat(core,schemas): add post custom ui assets api (#6118)

* feat(core,schemas): add post custom ui assets api
This commit is contained in:
Charles Zhao 2024-07-16 00:06:09 +08:00 committed by GitHub
parent 84ac935c80
commit ce3a62bc7a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 547 additions and 79 deletions

View file

@ -96,6 +96,7 @@
"@logto/cloud": "0.2.5-3046fa6",
"@silverhand/eslint-config": "6.0.1",
"@silverhand/ts-config": "6.0.0",
"@types/adm-zip": "^0.5.5",
"@types/debug": "^4.1.7",
"@types/etag": "^1.8.1",
"@types/jest": "^29.4.0",
@ -113,6 +114,7 @@
"@types/semver": "^7.3.12",
"@types/sinon": "^17.0.0",
"@types/supertest": "^6.0.2",
"adm-zip": "^0.5.14",
"eslint": "^8.56.0",
"jest": "^29.7.0",
"jest-matcher-specific-error": "^1.0.0",

View file

@ -92,7 +92,7 @@ export const mockSignInExperience: SignInExperience = {
customCss: null,
customContent: {},
agreeToTermsPolicy: AgreeToTermsPolicy.Automatic,
customUiAssetId: null,
customUiAssets: null,
passwordPolicy: {},
mfa: {
policy: MfaPolicy.UserControlled,

View file

@ -32,6 +32,7 @@ describe('sign-in-experience query', () => {
signUp: JSON.stringify(mockSignInExperience.signUp),
socialSignInConnectorTargets: JSON.stringify(mockSignInExperience.socialSignInConnectorTargets),
customContent: JSON.stringify(mockSignInExperience.customContent),
customUiAssets: JSON.stringify(mockSignInExperience.customUiAssets),
passwordPolicy: JSON.stringify(mockSignInExperience.passwordPolicy),
mfa: JSON.stringify(mockSignInExperience.mfa),
socialSignIn: JSON.stringify(mockSignInExperience.socialSignIn),
@ -40,7 +41,7 @@ describe('sign-in-experience query', () => {
it('findDefaultSignInExperience', async () => {
/* eslint-disable sql/no-unsafe-query */
const expectSql = `
select "tenant_id", "id", "color", "branding", "language_info", "terms_of_use_url", "privacy_policy_url", "agree_to_terms_policy", "sign_in", "sign_up", "social_sign_in", "social_sign_in_connector_targets", "sign_in_mode", "custom_css", "custom_content", "custom_ui_asset_id", "password_policy", "mfa", "single_sign_on_enabled"
select "tenant_id", "id", "color", "branding", "language_info", "terms_of_use_url", "privacy_policy_url", "agree_to_terms_policy", "sign_in", "sign_up", "social_sign_in", "social_sign_in_connector_targets", "sign_in_mode", "custom_css", "custom_content", "custom_ui_assets", "password_policy", "mfa", "single_sign_on_enabled"
from "sign_in_experiences"
where "id"=$1
`;

View file

@ -8,6 +8,7 @@ import {
type UserAssets,
userAssetsGuard,
adminTenantId,
uploadFileGuard,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { format } from 'date-fns';
@ -18,7 +19,6 @@ import koaGuard from '#src/middleware/koa-guard.js';
import type { RouterInitArgs } from '#src/routes/types.js';
import SystemContext from '#src/tenants/SystemContext.js';
import assertThat from '#src/utils/assert-that.js';
import { uploadFileGuard } from '#src/utils/storage/consts.js';
import { buildUploadFile } from '#src/utils/storage/index.js';
import type { AuthedMeRouter } from './types.js';

View file

@ -0,0 +1,42 @@
{
"tags": [
{
"name": "Custom UI assets",
"description": "Endpoints for uploading custom UI assets for the sign-in experience. Users can upload a zip file containing custom HTML, CSS, and JavaScript files to replace and fully customize the sign-in experience."
},
{ "name": "Cloud only" },
{ "name": "Dev feature" }
],
"paths": {
"/api/sign-in-exp/default/custom-ui-assets": {
"post": {
"summary": "Upload custom UI assets",
"description": "Upload a zip file containing custom web assets such as HTML, CSS, and JavaScript files, then replace the default sign-in experience with the custom UI assets.",
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"properties": {
"file": {
"description": "The zip file containing custom web assets such as HTML, CSS, and JavaScript files."
}
}
}
}
}
},
"responses": {
"200": {
"description": "An JSON object containing the custom UI assets ID."
},
"400": {
"description": "Bad request. The request body is invalid."
},
"500": {
"description": "Failed to unzip or upload the custom UI assets to storage provider."
}
}
}
}
}
}

View file

@ -0,0 +1,133 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { Readable } from 'node:stream';
import { fileURLToPath } from 'node:url';
import { StorageProvider } from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { createMockUtils, pickDefault } from '@logto/shared/esm';
import AdmZip from 'adm-zip';
import pRetry from 'p-retry';
import { type Response } from 'supertest';
import SystemContext from '#src/tenants/SystemContext.js';
import { MockTenant } from '#src/test-utils/tenant.js';
import { createRequester } from '#src/utils/test-utils.js';
const { jest } = import.meta;
const { mockEsmWithActual } = createMockUtils(jest);
const experienceZipsProviderConfig = {
provider: StorageProvider.AzureStorage,
connectionString: 'connectionString',
container: 'container',
} satisfies {
provider: StorageProvider.AzureStorage;
connectionString: string;
container: string;
};
// eslint-disable-next-line @silverhand/fp/no-mutation
SystemContext.shared.experienceZipsProviderConfig = experienceZipsProviderConfig;
const mockedIsFileExisted = jest.fn(async (filename: string) => false);
const mockedDownloadFile = jest.fn();
await mockEsmWithActual('#src/utils/storage/azure-storage.js', () => ({
buildAzureStorage: () => ({
uploadFile: jest.fn(async () => 'https://fake.url'),
downloadFile: mockedDownloadFile,
isFileExisted: mockedIsFileExisted,
}),
}));
await mockEsmWithActual('#src/utils/tenant.js', () => ({
getTenantId: jest.fn().mockResolvedValue(['default']),
}));
await mockEsmWithActual('p-retry', () => ({
// Stub pRetry by overriding the default "exponential backoff",
// in order to make the test run faster.
default: async (input: <T>(retries: number) => T | PromiseLike<T>) =>
pRetry(input, { factor: 0 }),
}));
const mockedGenerateStandardId = jest.fn(generateStandardId);
await mockEsmWithActual('@logto/shared', () => ({
generateStandardId: mockedGenerateStandardId,
}));
const tenantContext = new MockTenant();
const signInExperiencesRoutes = await pickDefault(import('./index.js'));
const signInExperienceRequester = createRequester({
authedRoutes: signInExperiencesRoutes,
tenantContext,
});
const currentPath = path.dirname(fileURLToPath(import.meta.url));
const testFilesPath = path.join(currentPath, 'test-files');
const pathToZip = path.join(testFilesPath, 'assets.zip');
const uploadCustomUiAssets = async (filePath: string): Promise<Response> => {
const response = await signInExperienceRequester
.post('/sign-in-exp/default/custom-ui-assets')
.field('name', 'file')
.attach('file', filePath);
return response;
};
describe('POST /sign-in-exp/default/custom-ui-assets', () => {
beforeAll(async () => {
await fs.mkdir(testFilesPath);
});
afterEach(() => {
jest.clearAllMocks();
});
afterAll(async () => {
void fs.rm(testFilesPath, { force: true, recursive: true });
});
it('should fail if upload file is not a zip', async () => {
const pathToTxt = path.join(testFilesPath, 'foo.txt');
await fs.writeFile(pathToTxt, 'foo');
const response = await uploadCustomUiAssets(pathToTxt);
expect(response.status).toBe(400);
});
it('should upload custom ui assets', async () => {
mockedGenerateStandardId.mockReturnValue('custom-ui-asset-id');
const zip = new AdmZip();
zip.addFile('index.html', Buffer.from('<html></html>'));
await zip.writeZipPromise(pathToZip);
const response = await uploadCustomUiAssets(pathToZip);
expect(response.status).toBe(200);
expect(response.body.customUiAssetId).toBe('custom-ui-asset-id');
});
it('should fail if the error.log file exists', async () => {
mockedIsFileExisted.mockImplementation(async (filename: string) =>
filename.endsWith('error.log')
);
mockedDownloadFile.mockImplementation(async () => ({
readableStreamBody: Readable.from('Failed to unzip files!'),
}));
const response = await uploadCustomUiAssets(pathToZip);
expect(response.status).toBe(500);
expect(response.text).toBe('Failed to upload file to the storage provider.');
});
it('should fail if the upload zip always persists (unzipping azure function does not trigger)', async () => {
mockedIsFileExisted.mockImplementation(async (filename) => filename.endsWith('assets.zip'));
const response = await uploadCustomUiAssets(pathToZip);
expect(response.status).toBe(500);
expect(response.text).toBe('Failed to upload file to the storage provider.');
expect(mockedIsFileExisted).toHaveBeenCalledTimes(10);
});
});

View file

@ -0,0 +1,115 @@
import { readFile } from 'node:fs/promises';
import { uploadFileGuard, maxUploadFileSize } from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import pRetry, { AbortError } from 'p-retry';
import { object, z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import SystemContext from '#src/tenants/SystemContext.js';
import assertThat from '#src/utils/assert-that.js';
import { getConsoleLogFromContext } from '#src/utils/console.js';
import { streamToString } from '#src/utils/file.js';
import { buildAzureStorage } from '#src/utils/storage/azure-storage.js';
import { getTenantId } from '#src/utils/tenant.js';
import { type ManagementApiRouter, type RouterInitArgs } from '../../types.js';
const maxRetryCount = 5;
export default function customUiAssetsRoutes<T extends ManagementApiRouter>(
...[router]: RouterInitArgs<T>
) {
// TODO: Remove
if (!EnvSet.values.isDevFeaturesEnabled) {
return;
}
router.post(
'/sign-in-exp/default/custom-ui-assets',
koaGuard({
files: object({
file: uploadFileGuard.array().min(1).max(1),
}),
response: z.object({
customUiAssetId: z.string(),
}),
status: [200, 400, 500],
}),
async (ctx, next) => {
const { file: bodyFiles } = ctx.guard.files;
const file = bodyFiles[0];
assertThat(file, 'guard.invalid_input');
assertThat(file.size <= maxUploadFileSize, 'guard.file_size_exceeded');
assertThat(file.mimetype === 'application/zip', 'guard.mime_type_not_allowed');
const { experienceZipsProviderConfig } = SystemContext.shared;
assertThat(
experienceZipsProviderConfig?.provider === 'AzureStorage',
'storage.not_configured'
);
const { connectionString, container } = experienceZipsProviderConfig;
const { uploadFile, downloadFile, isFileExisted } = buildAzureStorage(
connectionString,
container
);
const [tenantId] = await getTenantId(ctx.URL);
assertThat(tenantId, 'guard.can_not_get_tenant_id');
const customUiAssetId = generateStandardId(8);
const objectKey = `${tenantId}/${customUiAssetId}/assets.zip`;
const errorLogObjectKey = `${tenantId}/${customUiAssetId}/error.log`;
try {
// Upload the zip file to `experience-zips` container, in which a blob trigger is configured,
// and an azure function will be executed automatically to unzip the file on blob received.
// If the unzipping process succeeds, the zip file will be removed and assets will be stored in
// `experience-blobs` container. If it fails, the error message will be written to `error.log` file.
await uploadFile(await readFile(file.filepath), objectKey, {
contentType: file.mimetype,
});
const hasUnzipCompleted = async (retryTimes: number) => {
if (retryTimes > maxRetryCount) {
throw new AbortError('Unzip timeout. Max retry count reached.');
}
const [hasZip, hasError] = await Promise.all([
isFileExisted(objectKey),
isFileExisted(errorLogObjectKey),
]);
if (hasZip) {
throw new Error('Unzip in progress...');
}
if (hasError) {
const errorLogBlob = await downloadFile(errorLogObjectKey);
const errorLog = await streamToString(errorLogBlob.readableStreamBody);
throw new AbortError(errorLog || 'Unzipping failed.');
}
};
await pRetry(hasUnzipCompleted, {
retries: maxRetryCount,
});
} catch (error: unknown) {
getConsoleLogFromContext(ctx).error(error);
throw new RequestError(
{
code: 'storage.upload_error',
status: 500,
},
{
details: error instanceof Error ? error.message : String(error),
}
);
}
ctx.body = { customUiAssetId };
return next();
}
);
}

View file

@ -8,9 +8,12 @@ import koaGuard from '#src/middleware/koa-guard.js';
import type { ManagementApiRouter, RouterInitArgs } from '../types.js';
import customUiAssetsRoutes from './custom-ui-assets/index.js';
export default function signInExperiencesRoutes<T extends ManagementApiRouter>(
...[router, { queries, libraries, connectors }]: RouterInitArgs<T>
...args: RouterInitArgs<T>
) {
const [router, { queries, libraries, connectors }] = args;
const { findDefaultSignInExperience, updateDefaultSignInExperience } = queries.signInExperiences;
const { deleteConnectorById } = queries.connectors;
const {
@ -118,4 +121,6 @@ export default function signInExperiencesRoutes<T extends ManagementApiRouter>(
return next();
}
);
customUiAssetsRoutes(...args);
}

View file

@ -156,6 +156,7 @@ const additionalTags = Object.freeze(
condArray<string>(
'Organization applications',
EnvSet.values.isDevFeaturesEnabled && 'Subject tokens',
EnvSet.values.isDevFeaturesEnabled && 'Custom UI assets',
'Organization users'
)
);

View file

@ -27,6 +27,8 @@ type RouteDictionary = Record<`${OpenAPIV3.HttpMethods} ${string}`, string>;
const devFeatureCustomRoutes: RouteDictionary = Object.freeze({
// Subject tokens
'post /subject-tokens': 'CreateSubjectToken',
// Custom UI assets
'post /sign-in-exp/default/custom-ui-assets': 'UploadCustomUiAssets',
});
export const customRoutes: Readonly<RouteDictionary> = Object.freeze({

View file

@ -6,6 +6,7 @@ import {
userAssetsServiceStatusGuard,
allowUploadMimeTypes,
maxUploadFileSize,
uploadFileGuard,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { format } from 'date-fns';
@ -16,7 +17,6 @@ import koaGuard from '#src/middleware/koa-guard.js';
import SystemContext from '#src/tenants/SystemContext.js';
import assertThat from '#src/utils/assert-that.js';
import { getConsoleLogFromContext } from '#src/utils/console.js';
import { uploadFileGuard } from '#src/utils/storage/consts.js';
import { buildUploadFile } from '#src/utils/storage/index.js';
import { getTenantId } from '#src/utils/tenant.js';

View file

@ -18,6 +18,8 @@ import { devConsole } from '#src/utils/console.js';
export default class SystemContext {
static shared = new SystemContext();
public storageProviderConfig?: StorageProviderData;
public experienceBlobsProviderConfig?: StorageProviderData;
public experienceZipsProviderConfig?: StorageProviderData;
public hostnameProviderConfig?: HostnameProviderData;
public protectedAppConfigProviderConfig?: ProtectedAppConfigProviderData;
public protectedAppHostnameProviderConfig?: HostnameProviderData;
@ -31,6 +33,20 @@ export default class SystemContext {
storageProviderDataGuard
);
})(),
(async () => {
this.experienceBlobsProviderConfig = await this.loadConfig(
pool,
StorageProviderKey.ExperienceBlobsProvider,
storageProviderDataGuard
);
})(),
(async () => {
this.experienceZipsProviderConfig = await this.loadConfig(
pool,
StorageProviderKey.ExperienceZipsProvider,
storageProviderDataGuard
);
})(),
(async () => {
this.hostnameProviderConfig = await this.loadConfig(
pool,

View file

@ -0,0 +1,16 @@
import { Readable } from 'node:stream';
import { streamToString } from './file.js';
describe('streamToString()', () => {
it('should return an empty string if the stream is empty', async () => {
const result = await streamToString();
expect(result).toBe('');
});
it('should return the stream content as a string', async () => {
const stream = Readable.from(['Hello', ' ', 'world', '!']);
const result = await streamToString(stream);
expect(result).toBe('Hello world!');
});
});

View file

@ -0,0 +1,17 @@
/**
* Read a Readable stream to a string
* @param stream - The Readable stream to read from
* @returns A promise that resolves to a string containing the stream's data
*/
export async function streamToString(stream?: NodeJS.ReadableStream): Promise<string> {
if (!stream) {
return '';
}
const chunks: Uint8Array[] = [];
for await (const chunk of stream) {
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
}
const buffer = Buffer.concat(chunks);
return buffer.toString('utf8');
}

View file

@ -1,10 +1,17 @@
import { BlobServiceClient } from '@azure/storage-blob';
import { type BlobDownloadResponseParsed, BlobServiceClient } from '@azure/storage-blob';
import type { UploadFile } from './types.js';
const defaultPublicDomain = 'blob.core.windows.net';
export const buildAzureStorage = (connectionString: string, container: string) => {
export const buildAzureStorage = (
connectionString: string,
container: string
): {
uploadFile: UploadFile;
downloadFile: (objectKey: string) => Promise<BlobDownloadResponseParsed>;
isFileExisted: (objectKey: string) => Promise<boolean>;
} => {
const blobServiceClient = BlobServiceClient.fromConnectionString(connectionString);
const containerClient = blobServiceClient.getContainerClient(container);
@ -24,5 +31,15 @@ export const buildAzureStorage = (connectionString: string, container: string) =
};
};
return { uploadFile };
const downloadFile = async (objectKey: string) => {
const blockBlobClient = containerClient.getBlockBlobClient(objectKey);
return blockBlobClient.download();
};
const isFileExisted = async (objectKey: string) => {
const blockBlobClient = containerClient.getBlockBlobClient(objectKey);
return blockBlobClient.exists();
};
return { uploadFile, downloadFile, isFileExisted };
};

View file

@ -1,8 +0,0 @@
import { number, object, string } from 'zod';
export const uploadFileGuard = object({
filepath: string(),
mimetype: string(),
originalFilename: string(),
size: number(),
});

View file

@ -106,7 +106,7 @@ export const mockSignInExperience: SignInExperience = {
customCss: null,
customContent: {},
agreeToTermsPolicy: AgreeToTermsPolicy.ManualRegistrationOnly,
customUiAssetId: null,
customUiAssets: null,
passwordPolicy: {},
mfa: {
policy: MfaPolicy.UserControlled,
@ -140,7 +140,7 @@ export const mockSignInExperienceSettings: SignInExperienceResponse = {
customCss: null,
customContent: {},
agreeToTermsPolicy: mockSignInExperience.agreeToTermsPolicy,
customUiAssetId: null,
customUiAssets: null,
passwordPolicy: {},
mfa: {
policy: MfaPolicy.UserControlled,

View file

@ -0,0 +1,20 @@
import { sql } from '@silverhand/slonik';
import type { AlterationScript } from '../lib/types/alteration.js';
const alteration: AlterationScript = {
up: async (pool) => {
await pool.query(sql`
alter table sign_in_experiences drop column custom_ui_asset_id;
alter table sign_in_experiences add column custom_ui_assets jsonb;
`);
},
down: async (pool) => {
await pool.query(sql`
alter table sign_in_experiences add column custom_ui_asset_id varchar(21);
alter table sign_in_experiences drop column custom_ui_assets;
`);
},
};
export default alteration;

View file

@ -109,3 +109,10 @@ export const mfaGuard = z.object({
});
export type Mfa = z.infer<typeof mfaGuard>;
export const customUiAssetsGuard = z.object({
id: z.string(),
createdAt: z.number(),
});
export type CustomUiAssets = z.infer<typeof customUiAssetsGuard>;

View file

@ -49,7 +49,7 @@ export const createDefaultSignInExperience = (
signInMode: SignInMode.SignInAndRegister,
customCss: null,
customContent: {},
customUiAssetId: null,
customUiAssets: null,
passwordPolicy: {},
mfa: {
factors: [],

View file

@ -61,16 +61,22 @@ export type StorageProviderData = z.infer<typeof storageProviderDataGuard>;
export enum StorageProviderKey {
StorageProvider = 'storageProvider',
ExperienceBlobsProvider = 'experienceBlobsProvider',
ExperienceZipsProvider = 'experienceZipsProvider',
}
export type StorageProviderType = {
[StorageProviderKey.StorageProvider]: StorageProviderData;
[StorageProviderKey.ExperienceBlobsProvider]: StorageProviderData;
[StorageProviderKey.ExperienceZipsProvider]: StorageProviderData;
};
export const storageProviderGuard: Readonly<{
[key in StorageProviderKey]: ZodType<StorageProviderType[key]>;
}> = Object.freeze({
[StorageProviderKey.StorageProvider]: storageProviderDataGuard,
[StorageProviderKey.ExperienceBlobsProvider]: storageProviderDataGuard,
[StorageProviderKey.ExperienceZipsProvider]: storageProviderDataGuard,
});
// Email service provider

View file

@ -32,3 +32,10 @@ export const userAssetsGuard = z.object({
});
export type UserAssets = z.infer<typeof userAssetsGuard>;
export const uploadFileGuard = z.object({
filepath: z.string(),
mimetype: z.string(),
originalFilename: z.string(),
size: z.number(),
});

View file

@ -19,7 +19,7 @@ create table sign_in_experiences (
sign_in_mode sign_in_mode not null default 'SignInAndRegister',
custom_css text,
custom_content jsonb /* @use CustomContent */ not null default '{}'::jsonb,
custom_ui_asset_id varchar(21),
custom_ui_assets jsonb /* @use CustomUiAssets */,
password_policy jsonb /* @use PartialPasswordPolicy */ not null default '{}'::jsonb,
mfa jsonb /* @use Mfa */ not null default '{}'::jsonb,
single_sign_on_enabled boolean not null default false,

187
pnpm-lock.yaml generated
View file

@ -3365,6 +3365,9 @@ importers:
'@silverhand/ts-config':
specifier: 6.0.0
version: 6.0.0(typescript@5.3.3)
'@types/adm-zip':
specifier: ^0.5.5
version: 0.5.5
'@types/debug':
specifier: ^4.1.7
version: 4.1.7
@ -3416,12 +3419,15 @@ importers:
'@types/supertest':
specifier: ^6.0.2
version: 6.0.2
adm-zip:
specifier: ^0.5.14
version: 0.5.14
eslint:
specifier: ^8.56.0
version: 8.57.0
jest:
specifier: ^29.7.0
version: 29.7.0(@types/node@20.10.4)
version: 29.7.0(@types/node@20.10.4)(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.3.3))
jest-matcher-specific-error:
specifier: ^1.0.0
version: 1.0.0
@ -3676,7 +3682,7 @@ importers:
version: 3.0.0
jest:
specifier: ^29.7.0
version: 29.7.0(@types/node@20.12.7)
version: 29.7.0(@types/node@20.12.7)(ts-node@10.9.2(@swc/core@1.3.52(@swc/helpers@0.5.1))(@types/node@20.12.7)(typescript@5.3.3))
jest-environment-jsdom:
specifier: ^29.7.0
version: 29.7.0
@ -3685,7 +3691,7 @@ importers:
version: 2.0.0
jest-transformer-svg:
specifier: ^2.0.0
version: 2.0.0(jest@29.7.0(@types/node@20.12.7))(react@18.2.0)
version: 2.0.0(jest@29.7.0(@types/node@20.12.7)(ts-node@10.9.2(@swc/core@1.3.52(@swc/helpers@0.5.1))(@types/node@20.12.7)(typescript@5.3.3)))(react@18.2.0)
js-base64:
specifier: ^3.7.5
version: 3.7.5
@ -3824,7 +3830,7 @@ importers:
version: 10.0.0
jest:
specifier: ^29.7.0
version: 29.7.0(@types/node@20.10.4)
version: 29.7.0(@types/node@20.10.4)(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.3.3))
jest-matcher-specific-error:
specifier: ^1.0.0
version: 1.0.0
@ -6403,6 +6409,9 @@ packages:
'@types/acorn@4.0.6':
resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==}
'@types/adm-zip@0.5.5':
resolution: {integrity: sha512-YCGstVMjc4LTY5uK9/obvxBya93axZOVOyf2GSUulADzmLhYE45u2nAssCs/fWBs1Ifq5Vat75JTPwd5XZoPJw==}
'@types/aria-query@5.0.1':
resolution: {integrity: sha512-XTIieEY+gvJ39ChLcB4If5zHtPxt3Syj5rgZR+e1ctpmK8NjPf0zFqsz4JpLJT0xla9GFDKjy8Cpu331nrmE1Q==}
@ -6874,6 +6883,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
adm-zip@0.5.14:
resolution: {integrity: sha512-DnyqqifT4Jrcvb8USYjp6FHtBpEIz1mnXu6pTRHZ0RL69LbQYiO+0lDFg5+OKA7U29oWSs3a/i8fhn8ZcceIWg==}
engines: {node: '>=12.0'}
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
@ -14581,6 +14594,41 @@ snapshots:
- supports-color
- ts-node
'@jest/core@29.7.0(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.3.3))':
dependencies:
'@jest/console': 29.7.0
'@jest/reporters': 29.7.0
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@types/node': 20.12.7
ansi-escapes: 4.3.2
chalk: 4.1.2
ci-info: 3.8.0
exit: 0.1.2
graceful-fs: 4.2.11
jest-changed-files: 29.7.0
jest-config: 29.7.0(@types/node@20.12.7)(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.3.3))
jest-haste-map: 29.7.0
jest-message-util: 29.7.0
jest-regex-util: 29.6.3
jest-resolve: 29.7.0
jest-resolve-dependencies: 29.7.0
jest-runner: 29.7.0
jest-runtime: 29.7.0
jest-snapshot: 29.7.0
jest-util: 29.7.0
jest-validate: 29.7.0
jest-watcher: 29.7.0
micromatch: 4.0.5
pretty-format: 29.7.0
slash: 3.0.0
strip-ansi: 6.0.1
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
- ts-node
'@jest/create-cache-key-function@27.5.1':
dependencies:
'@jest/types': 27.5.1
@ -15902,10 +15950,10 @@ snapshots:
eslint-config-prettier: 9.1.0(eslint@8.57.0)
eslint-config-xo: 0.44.0(eslint@8.57.0)
eslint-config-xo-typescript: 4.0.0(@typescript-eslint/eslint-plugin@7.7.0(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0)(typescript@5.3.3))(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0)(typescript@5.3.3)
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.29.1)(eslint@8.57.0)
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-consistent-default-export-name: 0.0.15
eslint-plugin-eslint-comments: 3.2.0(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-n: 17.2.1(eslint@8.57.0)
eslint-plugin-no-use-extend-native: 0.5.0
eslint-plugin-prettier: 5.1.3(eslint-config-prettier@9.1.0(eslint@8.57.0))(eslint@8.57.0)(prettier@3.0.0)
@ -16518,6 +16566,10 @@ snapshots:
dependencies:
'@types/estree': 1.0.5
'@types/adm-zip@0.5.5':
dependencies:
'@types/node': 20.12.7
'@types/aria-query@5.0.1': {}
'@types/babel__core@7.1.19':
@ -17149,6 +17201,8 @@ snapshots:
acorn@8.11.3: {}
adm-zip@0.5.14: {}
agent-base@6.0.2:
dependencies:
debug: 4.3.4
@ -17913,13 +17967,13 @@ snapshots:
dependencies:
lodash.get: 4.4.2
create-jest@29.7.0(@types/node@20.10.4):
create-jest@29.7.0(@types/node@20.10.4)(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.3.3)):
dependencies:
'@jest/types': 29.6.3
chalk: 4.1.2
exit: 0.1.2
graceful-fs: 4.2.11
jest-config: 29.7.0(@types/node@20.10.4)
jest-config: 29.7.0(@types/node@20.10.4)(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.3.3))
jest-util: 29.7.0
prompts: 2.4.2
transitivePeerDependencies:
@ -18727,13 +18781,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.29.1)(eslint@8.57.0):
eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0))(eslint@8.57.0):
dependencies:
debug: 4.3.4
enhanced-resolve: 5.16.0
eslint: 8.57.0
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
fast-glob: 3.3.2
get-tsconfig: 4.7.3
is-core-module: 2.13.1
@ -18744,14 +18798,14 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
eslint-module-utils@2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0):
eslint-module-utils@2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0):
dependencies:
debug: 3.2.7(supports-color@5.5.0)
optionalDependencies:
'@typescript-eslint/parser': 7.7.0(eslint@8.57.0)(typescript@5.3.3)
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.29.1)(eslint@8.57.0)
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0))(eslint@8.57.0)
transitivePeerDependencies:
- supports-color
@ -18773,7 +18827,7 @@ snapshots:
eslint: 8.57.0
ignore: 5.3.1
eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0):
eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0):
dependencies:
array-includes: 3.1.8
array.prototype.findlastindex: 1.2.5
@ -18783,7 +18837,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0)
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
hasown: 2.0.2
is-core-module: 2.13.1
is-glob: 4.0.3
@ -20244,35 +20298,16 @@ snapshots:
- babel-plugin-macros
- supports-color
jest-cli@29.7.0(@types/node@20.10.4):
jest-cli@29.7.0(@types/node@20.10.4)(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.3.3)):
dependencies:
'@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.3.52(@swc/helpers@0.5.1))(@types/node@20.12.7)(typescript@5.3.3))
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.3.3))
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
chalk: 4.1.2
create-jest: 29.7.0(@types/node@20.10.4)
create-jest: 29.7.0(@types/node@20.10.4)(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.3.3))
exit: 0.1.2
import-local: 3.1.0
jest-config: 29.7.0(@types/node@20.10.4)
jest-util: 29.7.0
jest-validate: 29.7.0
yargs: 17.7.2
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
- supports-color
- ts-node
jest-cli@29.7.0(@types/node@20.12.7):
dependencies:
'@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.3.52(@swc/helpers@0.5.1))(@types/node@20.12.7)(typescript@5.3.3))
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
chalk: 4.1.2
create-jest: 29.7.0(@types/node@20.12.7)(ts-node@10.9.2(@swc/core@1.3.52(@swc/helpers@0.5.1))(@types/node@20.12.7)(typescript@5.3.3))
exit: 0.1.2
import-local: 3.1.0
jest-config: 29.7.0(@types/node@20.12.7)(ts-node@10.9.2(@swc/core@1.3.52(@swc/helpers@0.5.1))(@types/node@20.12.7)(typescript@5.3.3))
jest-config: 29.7.0(@types/node@20.10.4)(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.3.3))
jest-util: 29.7.0
jest-validate: 29.7.0
yargs: 17.7.2
@ -20301,7 +20336,7 @@ snapshots:
- supports-color
- ts-node
jest-config@29.7.0(@types/node@20.10.4):
jest-config@29.7.0(@types/node@20.10.4)(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.3.3)):
dependencies:
'@babel/core': 7.24.4
'@jest/test-sequencer': 29.7.0
@ -20327,6 +20362,7 @@ snapshots:
strip-json-comments: 3.1.1
optionalDependencies:
'@types/node': 20.10.4
ts-node: 10.9.2(@types/node@20.10.4)(typescript@5.3.3)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
@ -20362,6 +20398,37 @@ snapshots:
- babel-plugin-macros
- supports-color
jest-config@29.7.0(@types/node@20.12.7)(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.3.3)):
dependencies:
'@babel/core': 7.24.4
'@jest/test-sequencer': 29.7.0
'@jest/types': 29.6.3
babel-jest: 29.7.0(@babel/core@7.24.4)
chalk: 4.1.2
ci-info: 3.8.0
deepmerge: 4.3.1
glob: 7.2.3
graceful-fs: 4.2.11
jest-circus: 29.7.0
jest-environment-node: 29.7.0
jest-get-type: 29.6.3
jest-regex-util: 29.6.3
jest-resolve: 29.7.0
jest-runner: 29.7.0
jest-util: 29.7.0
jest-validate: 29.7.0
micromatch: 4.0.5
parse-json: 5.2.0
pretty-format: 29.7.0
slash: 3.0.0
strip-json-comments: 3.1.1
optionalDependencies:
'@types/node': 20.12.7
ts-node: 10.9.2(@types/node@20.10.4)(typescript@5.3.3)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
jest-dev-server@10.0.0:
dependencies:
chalk: 4.1.2
@ -20618,11 +20685,6 @@ snapshots:
jest: 29.7.0(@types/node@20.12.7)(ts-node@10.9.2(@swc/core@1.3.52(@swc/helpers@0.5.1))(@types/node@20.12.7)(typescript@5.3.3))
react: 18.2.0
jest-transformer-svg@2.0.0(jest@29.7.0(@types/node@20.12.7))(react@18.2.0):
dependencies:
jest: 29.7.0(@types/node@20.12.7)
react: 18.2.0
jest-util@29.5.0:
dependencies:
'@jest/types': 29.6.3
@ -20675,24 +20737,12 @@ snapshots:
merge-stream: 2.0.0
supports-color: 8.1.1
jest@29.7.0(@types/node@20.10.4):
jest@29.7.0(@types/node@20.10.4)(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.3.3)):
dependencies:
'@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.3.52(@swc/helpers@0.5.1))(@types/node@20.12.7)(typescript@5.3.3))
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.3.3))
'@jest/types': 29.6.3
import-local: 3.1.0
jest-cli: 29.7.0(@types/node@20.10.4)
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
- supports-color
- ts-node
jest@29.7.0(@types/node@20.12.7):
dependencies:
'@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.3.52(@swc/helpers@0.5.1))(@types/node@20.12.7)(typescript@5.3.3))
'@jest/types': 29.6.3
import-local: 3.1.0
jest-cli: 29.7.0(@types/node@20.12.7)
jest-cli: 29.7.0(@types/node@20.10.4)(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.3.3))
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
@ -24186,6 +24236,25 @@ snapshots:
optionalDependencies:
'@swc/core': 1.3.52(@swc/helpers@0.5.1)
ts-node@10.9.2(@types/node@20.10.4)(typescript@5.3.3):
dependencies:
'@cspotcode/source-map-support': 0.8.1
'@tsconfig/node10': 1.0.9
'@tsconfig/node12': 1.0.11
'@tsconfig/node14': 1.0.3
'@tsconfig/node16': 1.0.4
'@types/node': 20.10.4
acorn: 8.10.0
acorn-walk: 8.2.0
arg: 4.1.3
create-require: 1.1.1
diff: 4.0.2
make-error: 1.3.6
typescript: 5.3.3
v8-compile-cache-lib: 3.0.1
yn: 3.1.1
optional: true
tsconfig-paths@3.15.0:
dependencies:
'@types/json5': 0.0.29