0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-07 23:01:25 -05:00

feat(core): apply all quota guard (#4187)

This commit is contained in:
wangsijie 2023-07-20 17:27:25 +08:00 committed by GitHub
parent d5885160cc
commit 34e907d1ec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 267 additions and 109 deletions

View file

@ -2,6 +2,7 @@ import { createMockUtils } from '@logto/shared/esm';
import { mockFreePlan } from '#src/__mocks__/subscription.js';
import { createMockCloudConnectionLibrary } from '#src/test-utils/cloud-connection.js';
import { createMockConnectorLibrary } from '#src/test-utils/connectors.js';
const { jest } = import.meta;
const { mockEsmWithActual } = createMockUtils(jest);
@ -14,6 +15,7 @@ const { getTenantSubscriptionPlan } = await mockEsmWithActual(
);
const cloudConnection = createMockCloudConnectionLibrary();
const connectors = createMockConnectorLibrary();
const { MockQueries } = await import('#src/test-utils/tenant.js');
const { createQuotaLibrary } = await import('./quota.js');
@ -28,16 +30,16 @@ describe('guardKey()', () => {
getTenantSubscriptionPlan.mockClear();
});
const { guardKey } = createQuotaLibrary(queries, cloudConnection);
const { guardKey } = createQuotaLibrary(queries, cloudConnection, connectors);
it('should pass when limit is not exeeded', async () => {
countNonM2mApplications.mockResolvedValueOnce(0);
countNonM2mApplications.mockResolvedValueOnce({ count: 0 });
await expect(guardKey('applicationsLimit')).resolves.not.toThrow();
});
it('should throw when limit is exeeded', async () => {
countNonM2mApplications.mockResolvedValueOnce(mockFreePlan.quota.applicationsLimit);
countNonM2mApplications.mockResolvedValueOnce({ count: mockFreePlan.quota.applicationsLimit });
await expect(guardKey('applicationsLimit')).rejects.toThrow();
});

View file

@ -1,3 +1,5 @@
import { ConnectorType } from '@logto/connector-kit';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import type Queries from '#src/tenants/Queries.js';
@ -6,36 +8,74 @@ import { getTenantSubscriptionPlan } from '#src/utils/subscription/index.js';
import { type FeatureQuota } from '#src/utils/subscription/types.js';
import { type CloudConnectionLibrary } from './cloud-connection.js';
import { type ConnectorLibrary } from './connector.js';
export type QuotaLibrary = ReturnType<typeof createQuotaLibrary>;
export const createQuotaLibrary = (queries: Queries, cloudConnection: CloudConnectionLibrary) => {
const notNumber = (): never => {
throw new Error('Only support usage query for numberic quota');
};
export const createQuotaLibrary = (
queries: Queries,
cloudConnection: CloudConnectionLibrary,
connectorLibraray: ConnectorLibrary
) => {
const {
applications: { countNonM2mApplications, countM2mApplications },
resources: { findTotalNumberOfResources },
hooks: { getTotalNumberOfHooks },
roles: { countRoles },
scopes: { countScopesByResourceId },
rolesScopes: { countRolesScopesByRoleId },
} = queries;
const getTenantUsage = async (key: keyof FeatureQuota): Promise<number> => {
if (key === 'applicationsLimit') {
return countNonM2mApplications();
}
const { getLogtoConnectors } = connectorLibraray;
if (key === 'machineToMachineLimit') {
return countM2mApplications();
}
if (key === 'resourcesLimit') {
const tenantUsageQueries: Record<
keyof FeatureQuota,
(queryKey?: string) => Promise<{ count: number }>
> = {
applicationsLimit: countNonM2mApplications,
hooksLimit: getTotalNumberOfHooks,
machineToMachineLimit: countM2mApplications,
resourcesLimit: async () => {
const { count } = await findTotalNumberOfResources();
// Ignore the default management API resource
return count - 1;
}
// TODO: add other keys
throw new Error('Unsupported subscription quota key');
return { count: count - 1 };
},
rolesLimit: async () => countRoles(),
scopesPerResourceLimit: async (queryKey) => {
assertThat(queryKey, new TypeError('queryKey for scopesPerResourceLimit is required'));
return countScopesByResourceId(queryKey);
},
scopesPerRoleLimit: async (queryKey) => {
assertThat(queryKey, new TypeError('queryKey for scopesPerRoleLimit is required'));
return countRolesScopesByRoleId(queryKey);
},
socialConnectorsLimit: async () => {
const connectors = await getLogtoConnectors();
const count = connectors.filter(({ type }) => type === ConnectorType.Social).length;
return { count };
},
standardConnectorsLimit: async () => {
const connectors = await getLogtoConnectors();
const count = connectors.filter(({ metadata: { isStandard } }) => isStandard).length;
return { count };
},
customDomainEnabled: notNumber,
omniSignInEnabled: notNumber, // No limit for now
builtInEmailConnectorEnabled: notNumber, // No limit for now
};
const guardKey = async (key: keyof FeatureQuota) => {
const getTenantUsage = async (key: keyof FeatureQuota, queryKey?: string): Promise<number> => {
const query = tenantUsageQueries[key];
const { count } = await query(queryKey);
return count;
};
const guardKey = async (key: keyof FeatureQuota, queryKey?: string) => {
const { isCloud, isIntegrationTest, isProduction } = EnvSet.values;
// Cloud only feature, skip in non-cloud production environments
@ -56,6 +96,10 @@ export const createQuotaLibrary = (queries: Queries, cloudConnection: CloudConne
const plan = await getTenantSubscriptionPlan(cloudConnection);
const limit = plan.quota[key];
if (limit === null) {
return;
}
if (typeof limit === 'boolean') {
assertThat(
limit,
@ -68,7 +112,7 @@ export const createQuotaLibrary = (queries: Queries, cloudConnection: CloudConne
})
);
} else if (typeof limit === 'number') {
const tenantUsage = await getTenantUsage(key);
const tenantUsage = await getTenantUsage(key, queryKey);
assertThat(
tenantUsage < limit,

View file

@ -35,7 +35,7 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
where ${fields.type} != ${ApplicationType.MachineToMachine}
`);
return Number(count);
return { count: Number(count) };
};
const countM2mApplications = async () => {
const { count } = await pool.one<{ count: string }>(sql`
@ -44,7 +44,7 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
where ${fields.type} = ${ApplicationType.MachineToMachine}
`);
return Number(count);
return { count: Number(count) };
};
const deleteApplicationById = async (id: string) => {

View file

@ -18,6 +18,16 @@ export const createRolesScopesQueries = (pool: CommonQueryMethods) => {
)}
`);
const countRolesScopesByRoleId = async (roleId: string) => {
const { count } = await pool.one<{ count: string }>(sql`
select count(*)
from ${table}
where ${fields.roleId}=${roleId}
`);
return { count: Number(count) };
};
const findRolesScopesByRoleId = async (roleId: string) =>
pool.any<RolesScope>(sql`
select ${sql.join(Object.values(fields), sql`,`)}
@ -45,5 +55,11 @@ export const createRolesScopesQueries = (pool: CommonQueryMethods) => {
}
};
return { insertRolesScopes, findRolesScopesByRoleId, findRolesScopesByRoleIds, deleteRolesScope };
return {
insertRolesScopes,
findRolesScopesByRoleId,
findRolesScopesByRoleIds,
deleteRolesScope,
countRolesScopesByRoleId,
};
};

View file

@ -30,8 +30,8 @@ export const createRolesQueries = (pool: CommonQueryMethods) => {
const countRoles = async (
search: Search = defaultUserSearch,
{ excludeRoleIds = [], roleIds }: { excludeRoleIds?: string[]; roleIds?: string[] } = {}
) =>
pool.one<{ count: number }>(sql`
) => {
const { count } = await pool.one<{ count: string }>(sql`
select count(*)
from ${table}
where (not starts_with(${fields.name}, ${internalRolePrefix}))
@ -47,6 +47,9 @@ export const createRolesQueries = (pool: CommonQueryMethods) => {
${buildRoleConditions(search)}
`);
return { count: Number(count) };
};
const findRoles = async (
search: Search,
limit?: number,

View file

@ -15,7 +15,11 @@ import { buildConditionsFromSearch } from '#src/utils/search.js';
const { table, fields } = convertToIdentifiers(Scopes, true);
const resources = convertToIdentifiers(Resources, true);
const buildResourceConditions = (search: Search) => {
const buildResourceConditions = (search?: Search) => {
if (!search) {
return sql``;
}
const hasSearch = search.matches.length > 0;
const searchFields = [Scopes.fields.id, Scopes.fields.name, Scopes.fields.description];
@ -56,14 +60,17 @@ export const createScopeQueries = (pool: CommonQueryMethods) => {
${conditionalSql(offset, (value) => sql`offset ${value}`)}
`);
const countScopesByResourceId = async (resourceId: string, search: Search) =>
pool.one<{ count: number }>(sql`
const countScopesByResourceId = async (resourceId: string, search?: Search) => {
const { count } = await pool.one<{ count: string }>(sql`
select count(*)
from ${table}
where ${fields.resourceId}=${resourceId}
${buildResourceConditions(search)}
`);
return { count: Number(count) };
};
const countScopesByScopeIds = async (scopeIds: string[], search: Search) =>
pool.one<{ count: number }>(sql`
select count(*)

View file

@ -0,0 +1,57 @@
import { connectorFactoryResponseGuard } from '@logto/schemas';
import { string, object } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import assertThat from '#src/utils/assert-that.js';
import { loadConnectorFactories, transpileConnectorFactory } from '#src/utils/connectors/index.js';
import type { AuthedRouter, RouterInitArgs } from '../types.js';
export default function connectorFactoryRoutes<T extends AuthedRouter>(
...[router]: RouterInitArgs<T>
) {
router.get(
'/connector-factories',
koaGuard({
response: connectorFactoryResponseGuard.array(),
status: [200],
}),
async (ctx, next) => {
const connectorFactories = await loadConnectorFactories();
ctx.body = connectorFactories.map((connectorFactory) =>
transpileConnectorFactory(connectorFactory)
);
return next();
}
);
router.get(
'/connector-factories/:id',
koaGuard({
params: object({ id: string().min(1) }),
response: connectorFactoryResponseGuard,
status: [200, 404],
}),
async (ctx, next) => {
const {
params: { id },
} = ctx.guard;
const connectorFactories = await loadConnectorFactories();
const connectorFactory = connectorFactories.find((factory) => factory.metadata.id === id);
assertThat(
connectorFactory,
new RequestError({
code: 'entity.not_found',
status: 404,
})
);
ctx.body = transpileConnectorFactory(connectorFactory);
return next();
}
);
}

View file

@ -12,6 +12,7 @@ import {
} from '#src/__mocks__/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import type Queries from '#src/tenants/Queries.js';
import { createMockQuotaLibrary } from '#src/test-utils/quota.js';
import { MockTenant } from '#src/test-utils/tenant.js';
import assertThat from '#src/utils/assert-that.js';
import type { LogtoConnector } from '#src/utils/connectors/types.js';
@ -65,7 +66,9 @@ const tenantContext = new MockTenant(
return connector;
},
},
{}
{
quota: createMockQuotaLibrary(),
}
);
const connectorDataRoutes = await pickDefault(import('./index.js'));

View file

@ -1,35 +1,36 @@
import { buildRawConnector } from '@logto/cli/lib/connector/index.js';
import { type ConnectorFactory, buildRawConnector } from '@logto/cli/lib/connector/index.js';
import { demoConnectorIds, validateConfig } from '@logto/connector-kit';
import {
connectorFactoryResponseGuard,
Connectors,
ConnectorType,
connectorResponseGuard,
type JsonObject,
} from '@logto/schemas';
import { Connectors, ConnectorType, connectorResponseGuard, type JsonObject } from '@logto/schemas';
import { buildIdGenerator } from '@logto/shared';
import { conditional } from '@silverhand/essentials';
import cleanDeep from 'clean-deep';
import { string, object } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import { type QuotaLibrary } from '#src/libraries/quota.js';
import koaGuard from '#src/middleware/koa-guard.js';
import assertThat from '#src/utils/assert-that.js';
import { buildExtraInfo } from '#src/utils/connectors/extra-information.js';
import {
loadConnectorFactories,
transpileConnectorFactory,
transpileLogtoConnector,
} from '#src/utils/connectors/index.js';
import { loadConnectorFactories, transpileLogtoConnector } from '#src/utils/connectors/index.js';
import { checkSocialConnectorTargetAndPlatformUniqueness } from '#src/utils/connectors/platform.js';
import type { AuthedRouter, RouterInitArgs } from '../types.js';
import connectorAuthorizationUriRoutes from './authorization-uri.js';
import connectorConfigTestingRoutes from './config-testing.js';
import connectorFactoryRoutes from './factory.js';
const generateConnectorId = buildIdGenerator(12);
const guardConnectorsQuota = async (factory: ConnectorFactory, quota: QuotaLibrary) => {
if (factory.metadata.isStandard) {
await quota.guardKey('standardConnectorsLimit');
}
if (factory.type === ConnectorType.Social) {
await quota.guardKey('socialConnectorsLimit');
}
};
export default function connectorRoutes<T extends AuthedRouter>(
...[router, tenant]: RouterInitArgs<T>
) {
@ -43,6 +44,7 @@ export default function connectorRoutes<T extends AuthedRouter>(
} = tenant.queries.connectors;
const { getLogtoConnectorById, getLogtoConnectors } = tenant.connectors;
const {
quota,
signInExperiences: { removeUnavailableSocialConnectorTargets },
} = tenant.libraries;
@ -78,6 +80,8 @@ export default function connectorRoutes<T extends AuthedRouter>(
});
}
await guardConnectorsQuota(connectorFactory, quota);
assertThat(
connectorFactory.metadata.isStandard !== true || Boolean(metadata?.target),
'connector.should_specify_target'
@ -221,50 +225,6 @@ export default function connectorRoutes<T extends AuthedRouter>(
}
);
router.get(
'/connector-factories',
koaGuard({
response: connectorFactoryResponseGuard.array(),
status: [200],
}),
async (ctx, next) => {
const connectorFactories = await loadConnectorFactories();
ctx.body = connectorFactories.map((connectorFactory) =>
transpileConnectorFactory(connectorFactory)
);
return next();
}
);
router.get(
'/connector-factories/:id',
koaGuard({
params: object({ id: string().min(1) }),
response: connectorFactoryResponseGuard,
status: [200, 404],
}),
async (ctx, next) => {
const {
params: { id },
} = ctx.guard;
const connectorFactories = await loadConnectorFactories();
const connectorFactory = connectorFactories.find((factory) => factory.metadata.id === id);
assertThat(
connectorFactory,
new RequestError({
code: 'entity.not_found',
status: 404,
})
);
ctx.body = transpileConnectorFactory(connectorFactory);
return next();
}
);
router.patch(
'/connectors/:id',
koaGuard({
@ -360,4 +320,5 @@ export default function connectorRoutes<T extends AuthedRouter>(
connectorConfigTestingRoutes(router, tenant);
connectorAuthorizationUriRoutes(router, tenant);
connectorFactoryRoutes(router, tenant);
}

View file

@ -2,6 +2,7 @@ import { type Domain } from '@logto/schemas';
import { pickDefault } from '@logto/shared/esm';
import { mockDomain, mockDomainResponse } from '#src/__mocks__/domain.js';
import { createMockQuotaLibrary } from '#src/test-utils/quota.js';
import { MockTenant } from '#src/test-utils/tenant.js';
import { createRequester } from '#src/utils/test-utils.js';
@ -33,6 +34,7 @@ const mockLibraries = {
addDomain,
deleteDomain,
},
quota: createMockQuotaLibrary(),
};
const tenantContext = new MockTenant(undefined, { domains }, undefined, mockLibraries);

View file

@ -4,6 +4,7 @@ import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import koaQuotaGuard from '#src/middleware/koa-quota-guard.js';
import assertThat from '#src/utils/assert-that.js';
import type { AuthedRouter, RouterInitArgs } from './types.js';
@ -15,6 +16,7 @@ export default function domainRoutes<T extends AuthedRouter>(
domains: { findAllDomains, findDomainById },
} = queries;
const {
quota,
domains: { syncDomainStatus, addDomain, deleteDomain },
} = libraries;
@ -54,6 +56,7 @@ export default function domainRoutes<T extends AuthedRouter>(
router.post(
'/domains',
koaQuotaGuard({ key: 'customDomainEnabled', quota }),
koaGuard({
body: Domains.createGuard.pick({ domain: true }),
response: domainResponseGuard,

View file

@ -17,6 +17,7 @@ import {
mockNanoIdForHook,
mockTenantIdForHook,
} from '#src/__mocks__/hook.js';
import { createMockQuotaLibrary } from '#src/test-utils/quota.js';
import { MockTenant } from '#src/test-utils/tenant.js';
import { createRequester } from '#src/utils/test-utils.js';
@ -85,6 +86,7 @@ const mockLibraries = {
attachExecutionStatsToHook,
testHook,
},
quota: createMockQuotaLibrary(),
};
const tenantContext = new MockTenant(undefined, mockQueries, undefined, mockLibraries);

View file

@ -7,6 +7,7 @@ import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js';
import koaQuotaGuard from '#src/middleware/koa-quota-guard.js';
import assertThat from '#src/utils/assert-that.js';
import type { AuthedRouter, RouterInitArgs } from './types.js';
@ -32,6 +33,7 @@ export default function hookRoutes<T extends AuthedRouter>(
const {
hooks: { attachExecutionStatsToHook, testHook },
quota,
} = libraries;
router.get(
@ -128,6 +130,7 @@ export default function hookRoutes<T extends AuthedRouter>(
router.post(
'/hooks',
koaQuotaGuard({ key: 'hooksLimit', quota }),
koaGuard({
body: Hooks.createGuard.omit({ id: true, signingKey: true }).extend({
events: nonemptyUniqueHookEventsGuard.optional(),

View file

@ -16,7 +16,13 @@ const resourceId = buildIdGenerator(21);
const scopeId = resourceId;
export default function resourceRoutes<T extends AuthedRouter>(
...[router, { queries, libraries }]: RouterInitArgs<T>
...[
router,
{
queries,
libraries: { quota, resources },
},
]: RouterInitArgs<T>
) {
const {
resources: {
@ -38,7 +44,7 @@ export default function resourceRoutes<T extends AuthedRouter>(
updateScopeById,
},
} = queries;
const { attachScopesToResources } = libraries.resources;
const { attachScopesToResources } = resources;
router.get(
'/resources',
@ -77,7 +83,7 @@ export default function resourceRoutes<T extends AuthedRouter>(
router.post(
'/resources',
koaQuotaGuard({ key: 'resourcesLimit', quota: libraries.quota }),
koaQuotaGuard({ key: 'resourcesLimit', quota }),
koaGuard({
// Intentionally omit `isDefault` since it'll affect other rows.
// Use the dedicated API `PATCH /resources/:id/is-default` to update.
@ -242,6 +248,8 @@ export default function resourceRoutes<T extends AuthedRouter>(
body,
} = ctx.guard;
await quota.guardKey('scopesPerResourceLimit', resourceId);
assertThat(!/\s/.test(body.name), 'scope.name_with_space');
assertThat(

View file

@ -3,6 +3,7 @@ import { pickDefault } from '@logto/shared/esm';
import { mockRole, mockScope, mockResource, mockScopeWithResource } from '#src/__mocks__/index.js';
import { mockId, mockStandardId } from '#src/test-utils/nanoid.js';
import { createMockQuotaLibrary } from '#src/test-utils/quota.js';
import { MockTenant } from '#src/test-utils/tenant.js';
import { createRequester } from '#src/utils/test-utils.js';
@ -51,13 +52,20 @@ const users = {
const roleRoutes = await pickDefault(import('./role.scope.js'));
const tenantContext = new MockTenant(undefined, {
users,
rolesScopes,
resources,
scopes,
roles,
});
const tenantContext = new MockTenant(
undefined,
{
users,
rolesScopes,
resources,
scopes,
roles,
},
undefined,
{
quota: createMockQuotaLibrary(),
}
);
describe('role scope routes', () => {
const roleRequester = createRequester({ authedRoutes: roleRoutes, tenantContext });

View file

@ -13,7 +13,13 @@ import { parseSearchParamsForSearch } from '#src/utils/search.js';
import type { AuthedRouter, RouterInitArgs } from './types.js';
export default function roleScopeRoutes<T extends AuthedRouter>(
...[router, { queries }]: RouterInitArgs<T>
...[
router,
{
queries,
libraries: { quota },
},
]: RouterInitArgs<T>
) {
const {
resources: { findResourcesByIds },
@ -106,6 +112,9 @@ export default function roleScopeRoutes<T extends AuthedRouter>(
} = ctx.guard;
await findRoleById(id);
await quota.guardKey('scopesPerRoleLimit', id);
const rolesScopes = await findRolesScopesByRoleId(id);
for (const scopeId of scopeIds) {

View file

@ -3,6 +3,7 @@ import { pickDefault } from '@logto/shared/esm';
import { mockRole, mockScope, mockUser, mockResource } from '#src/__mocks__/index.js';
import { mockId, mockStandardId } from '#src/test-utils/nanoid.js';
import { createMockQuotaLibrary } from '#src/test-utils/quota.js';
import { MockTenant } from '#src/test-utils/tenant.js';
import { createRequester } from '#src/utils/test-utils.js';
@ -71,14 +72,19 @@ const {
const roleRoutes = await pickDefault(import('./role.js'));
const tenantContext = new MockTenant(undefined, {
usersRoles,
users,
rolesScopes,
resources,
scopes,
roles,
});
const tenantContext = new MockTenant(
undefined,
{
usersRoles,
users,
rolesScopes,
resources,
scopes,
roles,
},
undefined,
{ quota: createMockQuotaLibrary() }
);
describe('role routes', () => {
const roleRequester = createRequester({ authedRoutes: roleRoutes, tenantContext });

View file

@ -7,6 +7,7 @@ import { object, string, z, number } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js';
import koaQuotaGuard from '#src/middleware/koa-quota-guard.js';
import koaRoleRlsErrorHandler from '#src/middleware/koa-role-rls-error-handler.js';
import assertThat from '#src/utils/assert-that.js';
import { parseSearchParamsForSearch } from '#src/utils/search.js';
@ -14,7 +15,13 @@ import { parseSearchParamsForSearch } from '#src/utils/search.js';
import type { AuthedRouter, RouterInitArgs } from './types.js';
export default function roleRoutes<T extends AuthedRouter>(
...[router, { queries }]: RouterInitArgs<T>
...[
router,
{
queries,
libraries: { quota },
},
]: RouterInitArgs<T>
) {
const {
rolesScopes: { insertRolesScopes },
@ -112,6 +119,7 @@ export default function roleRoutes<T extends AuthedRouter>(
router.post(
'/roles',
koaQuotaGuard({ key: 'rolesLimit', quota }),
koaGuard({
body: Roles.createGuard
.omit({ id: true })

View file

@ -25,7 +25,7 @@ export default class Libraries {
applications = createApplicationLibrary(this.queries);
verificationStatuses = createVerificationStatusLibrary(this.queries);
domains = createDomainLibrary(this.queries);
quota = createQuotaLibrary(this.queries, this.cloudConnection);
quota = createQuotaLibrary(this.queries, this.cloudConnection, this.connectors);
constructor(
public readonly tenantId: string,

View file

@ -0,0 +1,13 @@
import { type ConnectorLibrary } from '#src/libraries/connector.js';
const { jest } = import.meta;
export const createMockConnectorLibrary = (): ConnectorLibrary => {
return {
getCloudConnectionData: jest.fn(),
getConnectorConfig: jest.fn(),
getLogtoConnectors: jest.fn(),
getLogtoConnectorsWellKnown: jest.fn(),
getLogtoConnectorById: jest.fn(),
};
};

View file

@ -9,4 +9,7 @@ type RouteResponseType<T extends { search?: unknown; body?: unknown; response?:
export type SubscriptionPlan = RouteResponseType<GetRoutes['/api/subscription-plans']>[number];
export type FeatureQuota = Omit<SubscriptionPlan['quota'], 'tenantLimit' | 'mauLimit'>;
export type FeatureQuota = Omit<
SubscriptionPlan['quota'],
'tenantLimit' | 'mauLimit' | 'auditLogsRetentionDays'
>;