mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(core,phrases): add quota guard middleware (#4153)
feat(core,phrases): add usage guard middleware
This commit is contained in:
parent
e2fc6cb545
commit
34105e1579
41 changed files with 395 additions and 31 deletions
|
@ -41,7 +41,7 @@
|
|||
"@logto/shared": "workspace:^2.0.0",
|
||||
"@logto/ui": "workspace:*",
|
||||
"@silverhand/essentials": "^2.5.0",
|
||||
"@withtyped/client": "^0.7.17",
|
||||
"@withtyped/client": "^0.7.19",
|
||||
"chalk": "^5.0.0",
|
||||
"clean-deep": "^3.4.0",
|
||||
"date-fns": "^2.29.3",
|
||||
|
@ -81,7 +81,7 @@
|
|||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@logto/cloud": "0.2.5-cbbfdc2",
|
||||
"@logto/cloud": "0.2.5-4d5e389",
|
||||
"@silverhand/eslint-config": "4.0.1",
|
||||
"@silverhand/ts-config": "4.0.0",
|
||||
"@types/debug": "^4.1.7",
|
||||
|
|
25
packages/core/src/__mocks__/subscription.ts
Normal file
25
packages/core/src/__mocks__/subscription.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { type SubscriptionPlan } from '#src/utils/subscription/types.js';
|
||||
|
||||
export const mockFreePlan: SubscriptionPlan = {
|
||||
id: 'free',
|
||||
name: 'Free',
|
||||
stripeProducts: [],
|
||||
quota: {
|
||||
mauLimit: 5000,
|
||||
hooksLimit: 1,
|
||||
rolesLimit: 1,
|
||||
resourcesLimit: 3,
|
||||
applicationsLimit: 3,
|
||||
omniSignInEnabled: true,
|
||||
scopesPerRoleLimit: 1,
|
||||
customDomainEnabled: false,
|
||||
machineToMachineLimit: 0,
|
||||
socialConnectorsLimit: 3,
|
||||
auditLogsRetentionDays: 3,
|
||||
scopesPerResourceLimit: 1,
|
||||
standardConnectorsLimit: 0,
|
||||
builtInEmailConnectorEnabled: true,
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
|
@ -87,7 +87,8 @@ export class CloudConnectionLibrary {
|
|||
const { endpoint } = await this.getCloudConnectionData();
|
||||
|
||||
this.client = new Client<typeof router>({
|
||||
baseUrl: endpoint,
|
||||
// TODO @sijie @darcy remove the 'api' appending in getCloudConnectionData()
|
||||
baseUrl: endpoint.replace('/api', ''),
|
||||
headers: async () => {
|
||||
return { Authorization: `Bearer ${await this.getAccessToken()}` };
|
||||
},
|
||||
|
|
97
packages/core/src/middleware/koa-quota-guard.test.ts
Normal file
97
packages/core/src/middleware/koa-quota-guard.test.ts
Normal file
|
@ -0,0 +1,97 @@
|
|||
import { GlobalValues } from '@logto/shared';
|
||||
import { createMockUtils } from '@logto/shared/esm';
|
||||
import { type Context } from 'koa';
|
||||
|
||||
import { mockFreePlan } from '#src/__mocks__/subscription.js';
|
||||
import { createMockCloudConnectionLibrary } from '#src/test-utils/cloud-connection.js';
|
||||
import createMockContext from '#src/test-utils/jest-koa-mocks/create-mock-context.js';
|
||||
import { MockQueries } from '#src/test-utils/tenant.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
const { mockEsmWithActual } = createMockUtils(jest);
|
||||
|
||||
const getValues = jest.fn(() => ({
|
||||
...new GlobalValues(),
|
||||
isCloud: true,
|
||||
}));
|
||||
|
||||
await mockEsmWithActual('#src/env-set/index.js', () => ({
|
||||
EnvSet: {
|
||||
get values() {
|
||||
return getValues();
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { getTenantSubscriptionPlan } = await mockEsmWithActual(
|
||||
'#src/utils/subscription/index.js',
|
||||
() => ({
|
||||
getTenantSubscriptionPlan: jest.fn().mockResolvedValue(mockFreePlan),
|
||||
})
|
||||
);
|
||||
|
||||
const { default: koaQuotaGuard } = await import('./koa-quota-guard.js');
|
||||
|
||||
const createContext = (): Context => {
|
||||
return createMockContext();
|
||||
};
|
||||
|
||||
const countNonM2MApplications = jest.fn();
|
||||
const queries = new MockQueries({
|
||||
applications: { countNonM2MApplications },
|
||||
});
|
||||
|
||||
const cloudConnection = createMockCloudConnectionLibrary();
|
||||
|
||||
describe('koaQuotaGuard() middleware', () => {
|
||||
afterEach(() => {
|
||||
getTenantSubscriptionPlan.mockClear();
|
||||
getValues.mockReturnValue({
|
||||
...new GlobalValues(),
|
||||
isCloud: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip on non-cloud', async () => {
|
||||
getValues.mockReturnValueOnce({
|
||||
...new GlobalValues(),
|
||||
isCloud: false,
|
||||
});
|
||||
|
||||
const ctx = createContext();
|
||||
await koaQuotaGuard({
|
||||
key: 'applicationsLimit',
|
||||
queries,
|
||||
cloudConnection,
|
||||
})(ctx, jest.fn());
|
||||
|
||||
expect(getTenantSubscriptionPlan).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass when limit is not exeeded', async () => {
|
||||
countNonM2MApplications.mockResolvedValueOnce(0);
|
||||
|
||||
const ctx = createContext();
|
||||
await expect(
|
||||
koaQuotaGuard({
|
||||
key: 'applicationsLimit',
|
||||
queries,
|
||||
cloudConnection,
|
||||
})(ctx, jest.fn())
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw when limit is exeeded', async () => {
|
||||
countNonM2MApplications.mockResolvedValueOnce(mockFreePlan.quota.applicationsLimit);
|
||||
|
||||
const ctx = createContext();
|
||||
await expect(
|
||||
koaQuotaGuard({
|
||||
key: 'applicationsLimit',
|
||||
queries,
|
||||
cloudConnection,
|
||||
})(ctx, jest.fn())
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
75
packages/core/src/middleware/koa-quota-guard.ts
Normal file
75
packages/core/src/middleware/koa-quota-guard.ts
Normal file
|
@ -0,0 +1,75 @@
|
|||
import type { MiddlewareType } from 'koa';
|
||||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js';
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
import { getTenantSubscriptionPlan } from '#src/utils/subscription/index.js';
|
||||
import { type FeatureQuota } from '#src/utils/subscription/types.js';
|
||||
|
||||
type UsageGuardConfig = {
|
||||
key: keyof FeatureQuota;
|
||||
cloudConnection: CloudConnectionLibrary;
|
||||
queries: Queries;
|
||||
};
|
||||
|
||||
const getTenantUsage = async (key: keyof FeatureQuota, queries: Queries): Promise<number> => {
|
||||
if (key === 'applicationsLimit') {
|
||||
return queries.applications.countNonM2MApplications();
|
||||
}
|
||||
|
||||
// TODO: add other keys
|
||||
|
||||
throw new Error('Unsupported subscription quota key');
|
||||
};
|
||||
|
||||
export default function koaQuotaGuard<StateT, ContextT, ResponseBodyT>({
|
||||
key,
|
||||
queries,
|
||||
cloudConnection,
|
||||
}: UsageGuardConfig): MiddlewareType<StateT, ContextT, ResponseBodyT> {
|
||||
return async (ctx, next) => {
|
||||
const { isCloud, isIntegrationTest, isProduction } = EnvSet.values;
|
||||
|
||||
// Disable in production until pricing is ready
|
||||
if (!isCloud || isIntegrationTest || isProduction) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const plan = await getTenantSubscriptionPlan(cloudConnection);
|
||||
const limit = plan.quota[key];
|
||||
|
||||
if (typeof limit === 'boolean') {
|
||||
assertThat(
|
||||
limit,
|
||||
new RequestError({
|
||||
code: 'subscription.limit_exceeded',
|
||||
status: 403,
|
||||
data: {
|
||||
key,
|
||||
},
|
||||
})
|
||||
);
|
||||
} else if (typeof limit === 'number') {
|
||||
const tenantUsage = await getTenantUsage(key, queries);
|
||||
|
||||
assertThat(
|
||||
tenantUsage < limit,
|
||||
new RequestError({
|
||||
code: 'subscription.limit_exceeded',
|
||||
status: 403,
|
||||
data: {
|
||||
key,
|
||||
limit,
|
||||
usage: tenantUsage,
|
||||
},
|
||||
})
|
||||
);
|
||||
} else {
|
||||
throw new TypeError('Unsupported subscription quota type');
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import type { CreateApplication } from '@logto/schemas';
|
||||
import { Applications } from '@logto/schemas';
|
||||
import { ApplicationType, Applications } from '@logto/schemas';
|
||||
import type { OmitAutoSetFields } from '@logto/shared';
|
||||
import { convertToIdentifiers } from '@logto/shared';
|
||||
import type { CommonQueryMethods } from 'slonik';
|
||||
|
@ -28,6 +28,15 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
|
|||
id: string,
|
||||
set: Partial<OmitAutoSetFields<CreateApplication>>
|
||||
) => updateApplication({ set, where: { id }, jsonbMode: 'merge' });
|
||||
const countNonM2MApplications = async () => {
|
||||
const { count } = await pool.one<{ count: string }>(sql`
|
||||
select count(*)
|
||||
from ${table}
|
||||
where ${fields.type} != ${ApplicationType.MachineToMachine}
|
||||
`);
|
||||
|
||||
return Number(count);
|
||||
};
|
||||
|
||||
const deleteApplicationById = async (id: string) => {
|
||||
const { rowCount } = await pool.query(sql`
|
||||
|
@ -47,6 +56,7 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
|
|||
insertApplication,
|
||||
updateApplication,
|
||||
updateApplicationById,
|
||||
countNonM2MApplications,
|
||||
deleteApplicationById,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -11,6 +11,7 @@ import { boolean, object, string, 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 { buildOidcClientMetadata } from '#src/oidc/utils.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
|
@ -21,7 +22,7 @@ const includesInternalAdminRole = (roles: Readonly<Array<{ role: Role }>>) =>
|
|||
roles.some(({ role: { name } }) => name === InternalRole.Admin);
|
||||
|
||||
export default function applicationRoutes<T extends AuthedRouter>(
|
||||
...[router, { queries, id: tenantId }]: RouterInitArgs<T>
|
||||
...[router, { queries, id: tenantId, cloudConnection }]: RouterInitArgs<T>
|
||||
) {
|
||||
const {
|
||||
deleteApplicationById,
|
||||
|
@ -57,6 +58,7 @@ export default function applicationRoutes<T extends AuthedRouter>(
|
|||
|
||||
router.post(
|
||||
'/applications',
|
||||
koaQuotaGuard({ key: 'applicationsLimit', cloudConnection, queries }),
|
||||
koaGuard({
|
||||
body: Applications.createGuard
|
||||
.omit({ id: true, createdAt: true })
|
||||
|
|
21
packages/core/src/test-utils/cloud-connection.ts
Normal file
21
packages/core/src/test-utils/cloud-connection.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { mockGetCloudConnectionData } from '#src/__mocks__/cloud-connection.js';
|
||||
import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
type PublicPart<T> = { [K in keyof T]: T[K] };
|
||||
|
||||
export const createMockCloudConnectionLibrary = (): CloudConnectionLibrary => {
|
||||
class MockLibrary implements PublicPart<CloudConnectionLibrary> {
|
||||
public getCloudConnectionData = mockGetCloudConnectionData;
|
||||
|
||||
public getAccessToken = jest.fn();
|
||||
|
||||
public getClient = jest.fn();
|
||||
}
|
||||
|
||||
const library = new MockLibrary();
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return library as unknown as CloudConnectionLibrary;
|
||||
};
|
20
packages/core/src/utils/subscription/index.ts
Normal file
20
packages/core/src/utils/subscription/index.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js';
|
||||
|
||||
import assertThat from '../assert-that.js';
|
||||
|
||||
import { type SubscriptionPlan } from './types.js';
|
||||
|
||||
export const getTenantSubscriptionPlan = async (
|
||||
cloudConnection: CloudConnectionLibrary
|
||||
): Promise<SubscriptionPlan> => {
|
||||
const client = await cloudConnection.getClient();
|
||||
const [subscription, plans] = await Promise.all([
|
||||
client.get('/api/tenants/my/subscription'),
|
||||
client.get('/api/subscription-plans'),
|
||||
]);
|
||||
const plan = plans.find(({ id }) => id === subscription.planId);
|
||||
|
||||
assertThat(plan, 'subscription.get_plan_failed');
|
||||
|
||||
return plan;
|
||||
};
|
12
packages/core/src/utils/subscription/types.ts
Normal file
12
packages/core/src/utils/subscription/types.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import type router from '@logto/cloud/routes';
|
||||
import { type RouterRoutes } from '@withtyped/client';
|
||||
import { type z, type ZodType } from 'zod';
|
||||
|
||||
type GetRoutes = RouterRoutes<typeof router>['get'];
|
||||
|
||||
type RouteResponseType<T extends { search?: unknown; body?: unknown; response?: ZodType }> =
|
||||
z.infer<NonNullable<T['response']>>;
|
||||
|
||||
export type SubscriptionPlan = RouteResponseType<GetRoutes['/api/subscription-plans']>[number];
|
||||
|
||||
export type FeatureQuota = Omit<SubscriptionPlan['quota'], 'tenantLimit' | 'mauLimit'>;
|
|
@ -15,6 +15,7 @@ import scope from './scope.js';
|
|||
import session from './session.js';
|
||||
import sign_in_experiences from './sign-in-experiences.js';
|
||||
import storage from './storage.js';
|
||||
import subscription from './subscription.js';
|
||||
import swagger from './swagger.js';
|
||||
import user from './user.js';
|
||||
import verification_code from './verification-code.js';
|
||||
|
@ -40,6 +41,7 @@ const errors = {
|
|||
resource,
|
||||
hook,
|
||||
domain,
|
||||
subscription,
|
||||
};
|
||||
|
||||
export default errors;
|
||||
|
|
6
packages/phrases/src/locales/de/errors/subscription.ts
Normal file
6
packages/phrases/src/locales/de/errors/subscription.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
const subscription = {
|
||||
limit_exceeded: 'Sie haben das Limit Ihres Abonnementplans erreicht.',
|
||||
get_plan_failed: 'Fehler beim Abrufen des Abonnementplans für den Mandanten.',
|
||||
};
|
||||
|
||||
export default subscription;
|
|
@ -15,6 +15,7 @@ import scope from './scope.js';
|
|||
import session from './session.js';
|
||||
import sign_in_experiences from './sign-in-experiences.js';
|
||||
import storage from './storage.js';
|
||||
import subscription from './subscription.js';
|
||||
import swagger from './swagger.js';
|
||||
import user from './user.js';
|
||||
import verification_code from './verification-code.js';
|
||||
|
@ -40,6 +41,7 @@ const errors = {
|
|||
resource,
|
||||
hook,
|
||||
domain,
|
||||
subscription,
|
||||
};
|
||||
|
||||
export default errors;
|
||||
|
|
6
packages/phrases/src/locales/en/errors/subscription.ts
Normal file
6
packages/phrases/src/locales/en/errors/subscription.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
const subscription = {
|
||||
limit_exceeded: 'You have reached the limit of your subscription plan.',
|
||||
get_plan_failed: 'Unable to get subscription plan for tenant.',
|
||||
};
|
||||
|
||||
export default subscription;
|
|
@ -15,6 +15,7 @@ import scope from './scope.js';
|
|||
import session from './session.js';
|
||||
import sign_in_experiences from './sign-in-experiences.js';
|
||||
import storage from './storage.js';
|
||||
import subscription from './subscription.js';
|
||||
import swagger from './swagger.js';
|
||||
import user from './user.js';
|
||||
import verification_code from './verification-code.js';
|
||||
|
@ -40,6 +41,7 @@ const errors = {
|
|||
resource,
|
||||
hook,
|
||||
domain,
|
||||
subscription,
|
||||
};
|
||||
|
||||
export default errors;
|
||||
|
|
6
packages/phrases/src/locales/es/errors/subscription.ts
Normal file
6
packages/phrases/src/locales/es/errors/subscription.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
const subscription = {
|
||||
limit_exceeded: 'Has alcanzado el límite de tu plan de suscripción.',
|
||||
get_plan_failed: 'No se pudo obtener el plan de suscripción para el inquilino.',
|
||||
};
|
||||
|
||||
export default subscription;
|
|
@ -15,6 +15,7 @@ import scope from './scope.js';
|
|||
import session from './session.js';
|
||||
import sign_in_experiences from './sign-in-experiences.js';
|
||||
import storage from './storage.js';
|
||||
import subscription from './subscription.js';
|
||||
import swagger from './swagger.js';
|
||||
import user from './user.js';
|
||||
import verification_code from './verification-code.js';
|
||||
|
@ -40,6 +41,7 @@ const errors = {
|
|||
resource,
|
||||
hook,
|
||||
domain,
|
||||
subscription,
|
||||
};
|
||||
|
||||
export default errors;
|
||||
|
|
6
packages/phrases/src/locales/fr/errors/subscription.ts
Normal file
6
packages/phrases/src/locales/fr/errors/subscription.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
const subscription = {
|
||||
limit_exceeded: "Vous avez atteint la limite de votre plan d'abonnement.",
|
||||
get_plan_failed: "Échec de l'obtention du plan d'abonnement pour le locataire.",
|
||||
};
|
||||
|
||||
export default subscription;
|
|
@ -15,6 +15,7 @@ import scope from './scope.js';
|
|||
import session from './session.js';
|
||||
import sign_in_experiences from './sign-in-experiences.js';
|
||||
import storage from './storage.js';
|
||||
import subscription from './subscription.js';
|
||||
import swagger from './swagger.js';
|
||||
import user from './user.js';
|
||||
import verification_code from './verification-code.js';
|
||||
|
@ -40,6 +41,7 @@ const errors = {
|
|||
resource,
|
||||
hook,
|
||||
domain,
|
||||
subscription,
|
||||
};
|
||||
|
||||
export default errors;
|
||||
|
|
6
packages/phrases/src/locales/it/errors/subscription.ts
Normal file
6
packages/phrases/src/locales/it/errors/subscription.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
const subscription = {
|
||||
limit_exceeded: 'Hai raggiunto il limite del tuo piano di abbonamento.',
|
||||
get_plan_failed: "Impossibile ottenere il piano di abbonamento per l'inquilino.",
|
||||
};
|
||||
|
||||
export default subscription;
|
|
@ -15,6 +15,7 @@ import scope from './scope.js';
|
|||
import session from './session.js';
|
||||
import sign_in_experiences from './sign-in-experiences.js';
|
||||
import storage from './storage.js';
|
||||
import subscription from './subscription.js';
|
||||
import swagger from './swagger.js';
|
||||
import user from './user.js';
|
||||
import verification_code from './verification-code.js';
|
||||
|
@ -40,6 +41,7 @@ const errors = {
|
|||
resource,
|
||||
hook,
|
||||
domain,
|
||||
subscription,
|
||||
};
|
||||
|
||||
export default errors;
|
||||
|
|
6
packages/phrases/src/locales/ja/errors/subscription.ts
Normal file
6
packages/phrases/src/locales/ja/errors/subscription.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
const subscription = {
|
||||
limit_exceeded: '定めた定期プランの上限に達しました。',
|
||||
get_plan_failed: 'テナントのサブスクリプションプランを取得できませんでした。',
|
||||
};
|
||||
|
||||
export default subscription;
|
|
@ -15,6 +15,7 @@ import scope from './scope.js';
|
|||
import session from './session.js';
|
||||
import sign_in_experiences from './sign-in-experiences.js';
|
||||
import storage from './storage.js';
|
||||
import subscription from './subscription.js';
|
||||
import swagger from './swagger.js';
|
||||
import user from './user.js';
|
||||
import verification_code from './verification-code.js';
|
||||
|
@ -40,6 +41,7 @@ const errors = {
|
|||
resource,
|
||||
hook,
|
||||
domain,
|
||||
subscription,
|
||||
};
|
||||
|
||||
export default errors;
|
||||
|
|
6
packages/phrases/src/locales/ko/errors/subscription.ts
Normal file
6
packages/phrases/src/locales/ko/errors/subscription.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
const subscription = {
|
||||
limit_exceeded: '당신은 구독 플랜 한도에 도달하였습니다.',
|
||||
get_plan_failed: '임차인에 대한 구독 플랜을 가져올 수 없습니다.',
|
||||
};
|
||||
|
||||
export default subscription;
|
|
@ -15,6 +15,7 @@ import scope from './scope.js';
|
|||
import session from './session.js';
|
||||
import sign_in_experiences from './sign-in-experiences.js';
|
||||
import storage from './storage.js';
|
||||
import subscription from './subscription.js';
|
||||
import swagger from './swagger.js';
|
||||
import user from './user.js';
|
||||
import verification_code from './verification-code.js';
|
||||
|
@ -40,6 +41,7 @@ const errors = {
|
|||
resource,
|
||||
hook,
|
||||
domain,
|
||||
subscription,
|
||||
};
|
||||
|
||||
export default errors;
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
const subscription = {
|
||||
limit_exceeded: 'Osiągnąłeś limit swojego planu subskrypcji.',
|
||||
get_plan_failed: 'Nie można pobrać planu subskrypcji dla najemcy.',
|
||||
};
|
||||
|
||||
export default subscription;
|
|
@ -15,6 +15,7 @@ import scope from './scope.js';
|
|||
import session from './session.js';
|
||||
import sign_in_experiences from './sign-in-experiences.js';
|
||||
import storage from './storage.js';
|
||||
import subscription from './subscription.js';
|
||||
import swagger from './swagger.js';
|
||||
import user from './user.js';
|
||||
import verification_code from './verification-code.js';
|
||||
|
@ -40,6 +41,7 @@ const errors = {
|
|||
resource,
|
||||
hook,
|
||||
domain,
|
||||
subscription,
|
||||
};
|
||||
|
||||
export default errors;
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
const subscription = {
|
||||
limit_exceeded: 'Você atingiu o limite do seu plano de assinatura.',
|
||||
get_plan_failed: 'Não foi possível obter o plano de assinatura para o inquilino.',
|
||||
};
|
||||
|
||||
export default subscription;
|
|
@ -15,6 +15,7 @@ import scope from './scope.js';
|
|||
import session from './session.js';
|
||||
import sign_in_experiences from './sign-in-experiences.js';
|
||||
import storage from './storage.js';
|
||||
import subscription from './subscription.js';
|
||||
import swagger from './swagger.js';
|
||||
import user from './user.js';
|
||||
import verification_code from './verification-code.js';
|
||||
|
@ -40,6 +41,7 @@ const errors = {
|
|||
resource,
|
||||
hook,
|
||||
domain,
|
||||
subscription,
|
||||
};
|
||||
|
||||
export default errors;
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
const subscription = {
|
||||
limit_exceeded: 'Você atingiu o limite do seu plano de assinatura.',
|
||||
get_plan_failed: 'Não foi possível obter o plano de assinatura para o inquilino.', // UNTRANSLATED
|
||||
};
|
||||
|
||||
export default subscription;
|
|
@ -15,6 +15,7 @@ import scope from './scope.js';
|
|||
import session from './session.js';
|
||||
import sign_in_experiences from './sign-in-experiences.js';
|
||||
import storage from './storage.js';
|
||||
import subscription from './subscription.js';
|
||||
import swagger from './swagger.js';
|
||||
import user from './user.js';
|
||||
import verification_code from './verification-code.js';
|
||||
|
@ -40,6 +41,7 @@ const errors = {
|
|||
resource,
|
||||
hook,
|
||||
domain,
|
||||
subscription,
|
||||
};
|
||||
|
||||
export default errors;
|
||||
|
|
6
packages/phrases/src/locales/ru/errors/subscription.ts
Normal file
6
packages/phrases/src/locales/ru/errors/subscription.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
const subscription = {
|
||||
limit_exceeded: 'Вы достигли лимита вашего плана подписки.',
|
||||
get_plan_failed: 'Не удалось получить план подписки для арендатора.',
|
||||
};
|
||||
|
||||
export default subscription;
|
|
@ -15,6 +15,7 @@ import scope from './scope.js';
|
|||
import session from './session.js';
|
||||
import sign_in_experiences from './sign-in-experiences.js';
|
||||
import storage from './storage.js';
|
||||
import subscription from './subscription.js';
|
||||
import swagger from './swagger.js';
|
||||
import user from './user.js';
|
||||
import verification_code from './verification-code.js';
|
||||
|
@ -40,6 +41,7 @@ const errors = {
|
|||
resource,
|
||||
hook,
|
||||
domain,
|
||||
subscription,
|
||||
};
|
||||
|
||||
export default errors;
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
const subscription = {
|
||||
limit_exceeded: 'Abonelik planınızın limitine ulaştınız.',
|
||||
get_plan_failed: 'Abonelik planınızı almak için başarısız oldu.',
|
||||
};
|
||||
|
||||
export default subscription;
|
|
@ -15,6 +15,7 @@ import scope from './scope.js';
|
|||
import session from './session.js';
|
||||
import sign_in_experiences from './sign-in-experiences.js';
|
||||
import storage from './storage.js';
|
||||
import subscription from './subscription.js';
|
||||
import swagger from './swagger.js';
|
||||
import user from './user.js';
|
||||
import verification_code from './verification-code.js';
|
||||
|
@ -40,6 +41,7 @@ const errors = {
|
|||
resource,
|
||||
hook,
|
||||
domain,
|
||||
subscription,
|
||||
};
|
||||
|
||||
export default errors;
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
const subscription = {
|
||||
limit_exceeded: '您已达到订阅计划的限制。',
|
||||
get_plan_failed: '无法获取租户的订阅计划。',
|
||||
};
|
||||
|
||||
export default subscription;
|
|
@ -15,6 +15,7 @@ import scope from './scope.js';
|
|||
import session from './session.js';
|
||||
import sign_in_experiences from './sign-in-experiences.js';
|
||||
import storage from './storage.js';
|
||||
import subscription from './subscription.js';
|
||||
import swagger from './swagger.js';
|
||||
import user from './user.js';
|
||||
import verification_code from './verification-code.js';
|
||||
|
@ -40,6 +41,7 @@ const errors = {
|
|||
resource,
|
||||
hook,
|
||||
domain,
|
||||
subscription,
|
||||
};
|
||||
|
||||
export default errors;
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
const subscription = {
|
||||
limit_exceeded: '您已達到訂閱計劃的限制。',
|
||||
get_plan_failed: '無法取得租戶的訂閱計劃。',
|
||||
};
|
||||
|
||||
export default subscription;
|
|
@ -15,6 +15,7 @@ import scope from './scope.js';
|
|||
import session from './session.js';
|
||||
import sign_in_experiences from './sign-in-experiences.js';
|
||||
import storage from './storage.js';
|
||||
import subscription from './subscription.js';
|
||||
import swagger from './swagger.js';
|
||||
import user from './user.js';
|
||||
import verification_code from './verification-code.js';
|
||||
|
@ -40,6 +41,7 @@ const errors = {
|
|||
resource,
|
||||
hook,
|
||||
domain,
|
||||
subscription,
|
||||
};
|
||||
|
||||
export default errors;
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
const subscription = {
|
||||
limit_exceeded: '您已達到訂閱計劃的限制。',
|
||||
get_plan_failed: '無法為租戶獲取訂閱計劃。',
|
||||
};
|
||||
|
||||
export default subscription;
|
|
@ -3079,8 +3079,8 @@ importers:
|
|||
specifier: ^2.5.0
|
||||
version: 2.5.0
|
||||
'@withtyped/client':
|
||||
specifier: ^0.7.17
|
||||
version: 0.7.17(zod@3.20.2)
|
||||
specifier: ^0.7.19
|
||||
version: 0.7.19(zod@3.20.2)
|
||||
chalk:
|
||||
specifier: ^5.0.0
|
||||
version: 5.1.2
|
||||
|
@ -3194,8 +3194,8 @@ importers:
|
|||
version: 3.20.2
|
||||
devDependencies:
|
||||
'@logto/cloud':
|
||||
specifier: 0.2.5-cbbfdc2
|
||||
version: 0.2.5-cbbfdc2(zod@3.20.2)
|
||||
specifier: 0.2.5-4d5e389
|
||||
version: 0.2.5-4d5e389(zod@3.20.2)
|
||||
'@silverhand/eslint-config':
|
||||
specifier: 4.0.1
|
||||
version: 4.0.1(eslint@8.44.0)(prettier@3.0.0)(typescript@5.0.2)
|
||||
|
@ -7220,12 +7220,12 @@ packages:
|
|||
- zod
|
||||
dev: true
|
||||
|
||||
/@logto/cloud@0.2.5-cbbfdc2(zod@3.20.2):
|
||||
resolution: {integrity: sha512-qul8lxkbAczEHJSyramQJW3BaNAdJQHlryIzTAHpcWpNGZogP3aacJuF0neBnnn8mJqAWy1rIGAGnb4d16fEFQ==}
|
||||
/@logto/cloud@0.2.5-4f1d80b(zod@3.20.2):
|
||||
resolution: {integrity: sha512-AF0YnJiXDMS3HQ2ugZcwJRBkspvNtlXk992IwTNFZxbZdinpPoVmPnDnHuekACcQY5bFRgHjMB2/o/GKkdLpWg==}
|
||||
engines: {node: ^18.12.0}
|
||||
dependencies:
|
||||
'@silverhand/essentials': 2.7.0
|
||||
'@withtyped/server': 0.12.5(zod@3.20.2)
|
||||
'@withtyped/server': 0.12.7(zod@3.20.2)
|
||||
transitivePeerDependencies:
|
||||
- zod
|
||||
dev: true
|
||||
|
@ -9848,15 +9848,6 @@ packages:
|
|||
eslint-visitor-keys: 3.4.1
|
||||
dev: true
|
||||
|
||||
/@withtyped/client@0.7.17(zod@3.20.2):
|
||||
resolution: {integrity: sha512-D8kwJBKryALjNcjHRLyARRTVnlGU+iUwJ1AdDsoSv+Sum17GDxi0s4F5DYh97bfSmdn/K4SGFBI2e0MdHPP+mg==}
|
||||
dependencies:
|
||||
'@withtyped/server': 0.12.5(zod@3.20.2)
|
||||
'@withtyped/shared': 0.2.2
|
||||
transitivePeerDependencies:
|
||||
- zod
|
||||
dev: false
|
||||
|
||||
/@withtyped/client@0.7.19(zod@3.20.2):
|
||||
resolution: {integrity: sha512-cHmqCIsEMonrE9kDbu8YAPWWPHmy6imUGbUWQ2PGaPLTJagbgH3ABsxgnLiM0lm0LeuZFd3BHh+JYKRDUmS8cw==}
|
||||
dependencies:
|
||||
|
@ -9864,16 +9855,6 @@ packages:
|
|||
'@withtyped/shared': 0.2.2
|
||||
transitivePeerDependencies:
|
||||
- zod
|
||||
dev: true
|
||||
|
||||
/@withtyped/server@0.12.5(zod@3.20.2):
|
||||
resolution: {integrity: sha512-mKDPGCJzh0xna4Vi2zVsHg/ZfMNQHDV3+jvsral2PJdRzI8qU7au7I/ytU6Vr6BU1fFESkNrIuUpCmyfBAwX7g==}
|
||||
peerDependencies:
|
||||
zod: ^3.19.1
|
||||
dependencies:
|
||||
'@silverhand/essentials': 2.7.0
|
||||
'@withtyped/shared': 0.2.2
|
||||
zod: 3.20.2
|
||||
|
||||
/@withtyped/server@0.12.7(zod@3.20.2):
|
||||
resolution: {integrity: sha512-NNT78ZZmSZiEosxI3iW/kVx1KEG5vetvpEXNl0Gy58OlOnI8l/7h8Q//JZJ268xWOKyaNI4KrngTRtL5uvZu9Q==}
|
||||
|
|
Loading…
Reference in a new issue