0
Fork 0
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:
wangsijie 2023-07-18 17:05:00 +08:00 committed by GitHub
parent e2fc6cb545
commit 34105e1579
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 395 additions and 31 deletions

View file

@ -41,7 +41,7 @@
"@logto/shared": "workspace:^2.0.0", "@logto/shared": "workspace:^2.0.0",
"@logto/ui": "workspace:*", "@logto/ui": "workspace:*",
"@silverhand/essentials": "^2.5.0", "@silverhand/essentials": "^2.5.0",
"@withtyped/client": "^0.7.17", "@withtyped/client": "^0.7.19",
"chalk": "^5.0.0", "chalk": "^5.0.0",
"clean-deep": "^3.4.0", "clean-deep": "^3.4.0",
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
@ -81,7 +81,7 @@
"zod": "^3.20.2" "zod": "^3.20.2"
}, },
"devDependencies": { "devDependencies": {
"@logto/cloud": "0.2.5-cbbfdc2", "@logto/cloud": "0.2.5-4d5e389",
"@silverhand/eslint-config": "4.0.1", "@silverhand/eslint-config": "4.0.1",
"@silverhand/ts-config": "4.0.0", "@silverhand/ts-config": "4.0.0",
"@types/debug": "^4.1.7", "@types/debug": "^4.1.7",

View 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(),
};

View file

@ -87,7 +87,8 @@ export class CloudConnectionLibrary {
const { endpoint } = await this.getCloudConnectionData(); const { endpoint } = await this.getCloudConnectionData();
this.client = new Client<typeof router>({ this.client = new Client<typeof router>({
baseUrl: endpoint, // TODO @sijie @darcy remove the 'api' appending in getCloudConnectionData()
baseUrl: endpoint.replace('/api', ''),
headers: async () => { headers: async () => {
return { Authorization: `Bearer ${await this.getAccessToken()}` }; return { Authorization: `Bearer ${await this.getAccessToken()}` };
}, },

View 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();
});
});

View 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();
};
}

View file

@ -1,5 +1,5 @@
import type { CreateApplication } from '@logto/schemas'; import type { CreateApplication } from '@logto/schemas';
import { Applications } from '@logto/schemas'; import { ApplicationType, Applications } from '@logto/schemas';
import type { OmitAutoSetFields } from '@logto/shared'; import type { OmitAutoSetFields } from '@logto/shared';
import { convertToIdentifiers } from '@logto/shared'; import { convertToIdentifiers } from '@logto/shared';
import type { CommonQueryMethods } from 'slonik'; import type { CommonQueryMethods } from 'slonik';
@ -28,6 +28,15 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
id: string, id: string,
set: Partial<OmitAutoSetFields<CreateApplication>> set: Partial<OmitAutoSetFields<CreateApplication>>
) => updateApplication({ set, where: { id }, jsonbMode: 'merge' }); ) => 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 deleteApplicationById = async (id: string) => {
const { rowCount } = await pool.query(sql` const { rowCount } = await pool.query(sql`
@ -47,6 +56,7 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
insertApplication, insertApplication,
updateApplication, updateApplication,
updateApplicationById, updateApplicationById,
countNonM2MApplications,
deleteApplicationById, deleteApplicationById,
}; };
}; };

View file

@ -11,6 +11,7 @@ import { boolean, object, string, z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.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 { buildOidcClientMetadata } from '#src/oidc/utils.js';
import assertThat from '#src/utils/assert-that.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); roles.some(({ role: { name } }) => name === InternalRole.Admin);
export default function applicationRoutes<T extends AuthedRouter>( export default function applicationRoutes<T extends AuthedRouter>(
...[router, { queries, id: tenantId }]: RouterInitArgs<T> ...[router, { queries, id: tenantId, cloudConnection }]: RouterInitArgs<T>
) { ) {
const { const {
deleteApplicationById, deleteApplicationById,
@ -57,6 +58,7 @@ export default function applicationRoutes<T extends AuthedRouter>(
router.post( router.post(
'/applications', '/applications',
koaQuotaGuard({ key: 'applicationsLimit', cloudConnection, queries }),
koaGuard({ koaGuard({
body: Applications.createGuard body: Applications.createGuard
.omit({ id: true, createdAt: true }) .omit({ id: true, createdAt: true })

View 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;
};

View 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;
};

View 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'>;

View file

@ -15,6 +15,7 @@ import scope from './scope.js';
import session from './session.js'; import session from './session.js';
import sign_in_experiences from './sign-in-experiences.js'; import sign_in_experiences from './sign-in-experiences.js';
import storage from './storage.js'; import storage from './storage.js';
import subscription from './subscription.js';
import swagger from './swagger.js'; import swagger from './swagger.js';
import user from './user.js'; import user from './user.js';
import verification_code from './verification-code.js'; import verification_code from './verification-code.js';
@ -40,6 +41,7 @@ const errors = {
resource, resource,
hook, hook,
domain, domain,
subscription,
}; };
export default errors; export default errors;

View 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;

View file

@ -15,6 +15,7 @@ import scope from './scope.js';
import session from './session.js'; import session from './session.js';
import sign_in_experiences from './sign-in-experiences.js'; import sign_in_experiences from './sign-in-experiences.js';
import storage from './storage.js'; import storage from './storage.js';
import subscription from './subscription.js';
import swagger from './swagger.js'; import swagger from './swagger.js';
import user from './user.js'; import user from './user.js';
import verification_code from './verification-code.js'; import verification_code from './verification-code.js';
@ -40,6 +41,7 @@ const errors = {
resource, resource,
hook, hook,
domain, domain,
subscription,
}; };
export default errors; export default errors;

View 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;

View file

@ -15,6 +15,7 @@ import scope from './scope.js';
import session from './session.js'; import session from './session.js';
import sign_in_experiences from './sign-in-experiences.js'; import sign_in_experiences from './sign-in-experiences.js';
import storage from './storage.js'; import storage from './storage.js';
import subscription from './subscription.js';
import swagger from './swagger.js'; import swagger from './swagger.js';
import user from './user.js'; import user from './user.js';
import verification_code from './verification-code.js'; import verification_code from './verification-code.js';
@ -40,6 +41,7 @@ const errors = {
resource, resource,
hook, hook,
domain, domain,
subscription,
}; };
export default errors; export default errors;

View 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;

View file

@ -15,6 +15,7 @@ import scope from './scope.js';
import session from './session.js'; import session from './session.js';
import sign_in_experiences from './sign-in-experiences.js'; import sign_in_experiences from './sign-in-experiences.js';
import storage from './storage.js'; import storage from './storage.js';
import subscription from './subscription.js';
import swagger from './swagger.js'; import swagger from './swagger.js';
import user from './user.js'; import user from './user.js';
import verification_code from './verification-code.js'; import verification_code from './verification-code.js';
@ -40,6 +41,7 @@ const errors = {
resource, resource,
hook, hook,
domain, domain,
subscription,
}; };
export default errors; export default errors;

View 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;

View file

@ -15,6 +15,7 @@ import scope from './scope.js';
import session from './session.js'; import session from './session.js';
import sign_in_experiences from './sign-in-experiences.js'; import sign_in_experiences from './sign-in-experiences.js';
import storage from './storage.js'; import storage from './storage.js';
import subscription from './subscription.js';
import swagger from './swagger.js'; import swagger from './swagger.js';
import user from './user.js'; import user from './user.js';
import verification_code from './verification-code.js'; import verification_code from './verification-code.js';
@ -40,6 +41,7 @@ const errors = {
resource, resource,
hook, hook,
domain, domain,
subscription,
}; };
export default errors; export default errors;

View 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;

View file

@ -15,6 +15,7 @@ import scope from './scope.js';
import session from './session.js'; import session from './session.js';
import sign_in_experiences from './sign-in-experiences.js'; import sign_in_experiences from './sign-in-experiences.js';
import storage from './storage.js'; import storage from './storage.js';
import subscription from './subscription.js';
import swagger from './swagger.js'; import swagger from './swagger.js';
import user from './user.js'; import user from './user.js';
import verification_code from './verification-code.js'; import verification_code from './verification-code.js';
@ -40,6 +41,7 @@ const errors = {
resource, resource,
hook, hook,
domain, domain,
subscription,
}; };
export default errors; export default errors;

View file

@ -0,0 +1,6 @@
const subscription = {
limit_exceeded: '定めた定期プランの上限に達しました。',
get_plan_failed: 'テナントのサブスクリプションプランを取得できませんでした。',
};
export default subscription;

View file

@ -15,6 +15,7 @@ import scope from './scope.js';
import session from './session.js'; import session from './session.js';
import sign_in_experiences from './sign-in-experiences.js'; import sign_in_experiences from './sign-in-experiences.js';
import storage from './storage.js'; import storage from './storage.js';
import subscription from './subscription.js';
import swagger from './swagger.js'; import swagger from './swagger.js';
import user from './user.js'; import user from './user.js';
import verification_code from './verification-code.js'; import verification_code from './verification-code.js';
@ -40,6 +41,7 @@ const errors = {
resource, resource,
hook, hook,
domain, domain,
subscription,
}; };
export default errors; export default errors;

View file

@ -0,0 +1,6 @@
const subscription = {
limit_exceeded: '당신은 구독 플랜 한도에 도달하였습니다.',
get_plan_failed: '임차인에 대한 구독 플랜을 가져올 수 없습니다.',
};
export default subscription;

View file

@ -15,6 +15,7 @@ import scope from './scope.js';
import session from './session.js'; import session from './session.js';
import sign_in_experiences from './sign-in-experiences.js'; import sign_in_experiences from './sign-in-experiences.js';
import storage from './storage.js'; import storage from './storage.js';
import subscription from './subscription.js';
import swagger from './swagger.js'; import swagger from './swagger.js';
import user from './user.js'; import user from './user.js';
import verification_code from './verification-code.js'; import verification_code from './verification-code.js';
@ -40,6 +41,7 @@ const errors = {
resource, resource,
hook, hook,
domain, domain,
subscription,
}; };
export default errors; export default errors;

View file

@ -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;

View file

@ -15,6 +15,7 @@ import scope from './scope.js';
import session from './session.js'; import session from './session.js';
import sign_in_experiences from './sign-in-experiences.js'; import sign_in_experiences from './sign-in-experiences.js';
import storage from './storage.js'; import storage from './storage.js';
import subscription from './subscription.js';
import swagger from './swagger.js'; import swagger from './swagger.js';
import user from './user.js'; import user from './user.js';
import verification_code from './verification-code.js'; import verification_code from './verification-code.js';
@ -40,6 +41,7 @@ const errors = {
resource, resource,
hook, hook,
domain, domain,
subscription,
}; };
export default errors; export default errors;

View file

@ -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;

View file

@ -15,6 +15,7 @@ import scope from './scope.js';
import session from './session.js'; import session from './session.js';
import sign_in_experiences from './sign-in-experiences.js'; import sign_in_experiences from './sign-in-experiences.js';
import storage from './storage.js'; import storage from './storage.js';
import subscription from './subscription.js';
import swagger from './swagger.js'; import swagger from './swagger.js';
import user from './user.js'; import user from './user.js';
import verification_code from './verification-code.js'; import verification_code from './verification-code.js';
@ -40,6 +41,7 @@ const errors = {
resource, resource,
hook, hook,
domain, domain,
subscription,
}; };
export default errors; export default errors;

View file

@ -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;

View file

@ -15,6 +15,7 @@ import scope from './scope.js';
import session from './session.js'; import session from './session.js';
import sign_in_experiences from './sign-in-experiences.js'; import sign_in_experiences from './sign-in-experiences.js';
import storage from './storage.js'; import storage from './storage.js';
import subscription from './subscription.js';
import swagger from './swagger.js'; import swagger from './swagger.js';
import user from './user.js'; import user from './user.js';
import verification_code from './verification-code.js'; import verification_code from './verification-code.js';
@ -40,6 +41,7 @@ const errors = {
resource, resource,
hook, hook,
domain, domain,
subscription,
}; };
export default errors; export default errors;

View file

@ -0,0 +1,6 @@
const subscription = {
limit_exceeded: 'Вы достигли лимита вашего плана подписки.',
get_plan_failed: 'Не удалось получить план подписки для арендатора.',
};
export default subscription;

View file

@ -15,6 +15,7 @@ import scope from './scope.js';
import session from './session.js'; import session from './session.js';
import sign_in_experiences from './sign-in-experiences.js'; import sign_in_experiences from './sign-in-experiences.js';
import storage from './storage.js'; import storage from './storage.js';
import subscription from './subscription.js';
import swagger from './swagger.js'; import swagger from './swagger.js';
import user from './user.js'; import user from './user.js';
import verification_code from './verification-code.js'; import verification_code from './verification-code.js';
@ -40,6 +41,7 @@ const errors = {
resource, resource,
hook, hook,
domain, domain,
subscription,
}; };
export default errors; export default errors;

View file

@ -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;

View file

@ -15,6 +15,7 @@ import scope from './scope.js';
import session from './session.js'; import session from './session.js';
import sign_in_experiences from './sign-in-experiences.js'; import sign_in_experiences from './sign-in-experiences.js';
import storage from './storage.js'; import storage from './storage.js';
import subscription from './subscription.js';
import swagger from './swagger.js'; import swagger from './swagger.js';
import user from './user.js'; import user from './user.js';
import verification_code from './verification-code.js'; import verification_code from './verification-code.js';
@ -40,6 +41,7 @@ const errors = {
resource, resource,
hook, hook,
domain, domain,
subscription,
}; };
export default errors; export default errors;

View file

@ -0,0 +1,6 @@
const subscription = {
limit_exceeded: '您已达到订阅计划的限制。',
get_plan_failed: '无法获取租户的订阅计划。',
};
export default subscription;

View file

@ -15,6 +15,7 @@ import scope from './scope.js';
import session from './session.js'; import session from './session.js';
import sign_in_experiences from './sign-in-experiences.js'; import sign_in_experiences from './sign-in-experiences.js';
import storage from './storage.js'; import storage from './storage.js';
import subscription from './subscription.js';
import swagger from './swagger.js'; import swagger from './swagger.js';
import user from './user.js'; import user from './user.js';
import verification_code from './verification-code.js'; import verification_code from './verification-code.js';
@ -40,6 +41,7 @@ const errors = {
resource, resource,
hook, hook,
domain, domain,
subscription,
}; };
export default errors; export default errors;

View file

@ -0,0 +1,6 @@
const subscription = {
limit_exceeded: '您已達到訂閱計劃的限制。',
get_plan_failed: '無法取得租戶的訂閱計劃。',
};
export default subscription;

View file

@ -15,6 +15,7 @@ import scope from './scope.js';
import session from './session.js'; import session from './session.js';
import sign_in_experiences from './sign-in-experiences.js'; import sign_in_experiences from './sign-in-experiences.js';
import storage from './storage.js'; import storage from './storage.js';
import subscription from './subscription.js';
import swagger from './swagger.js'; import swagger from './swagger.js';
import user from './user.js'; import user from './user.js';
import verification_code from './verification-code.js'; import verification_code from './verification-code.js';
@ -40,6 +41,7 @@ const errors = {
resource, resource,
hook, hook,
domain, domain,
subscription,
}; };
export default errors; export default errors;

View file

@ -0,0 +1,6 @@
const subscription = {
limit_exceeded: '您已達到訂閱計劃的限制。',
get_plan_failed: '無法為租戶獲取訂閱計劃。',
};
export default subscription;

View file

@ -3079,8 +3079,8 @@ importers:
specifier: ^2.5.0 specifier: ^2.5.0
version: 2.5.0 version: 2.5.0
'@withtyped/client': '@withtyped/client':
specifier: ^0.7.17 specifier: ^0.7.19
version: 0.7.17(zod@3.20.2) version: 0.7.19(zod@3.20.2)
chalk: chalk:
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.1.2 version: 5.1.2
@ -3194,8 +3194,8 @@ importers:
version: 3.20.2 version: 3.20.2
devDependencies: devDependencies:
'@logto/cloud': '@logto/cloud':
specifier: 0.2.5-cbbfdc2 specifier: 0.2.5-4d5e389
version: 0.2.5-cbbfdc2(zod@3.20.2) version: 0.2.5-4d5e389(zod@3.20.2)
'@silverhand/eslint-config': '@silverhand/eslint-config':
specifier: 4.0.1 specifier: 4.0.1
version: 4.0.1(eslint@8.44.0)(prettier@3.0.0)(typescript@5.0.2) version: 4.0.1(eslint@8.44.0)(prettier@3.0.0)(typescript@5.0.2)
@ -7220,12 +7220,12 @@ packages:
- zod - zod
dev: true dev: true
/@logto/cloud@0.2.5-cbbfdc2(zod@3.20.2): /@logto/cloud@0.2.5-4f1d80b(zod@3.20.2):
resolution: {integrity: sha512-qul8lxkbAczEHJSyramQJW3BaNAdJQHlryIzTAHpcWpNGZogP3aacJuF0neBnnn8mJqAWy1rIGAGnb4d16fEFQ==} resolution: {integrity: sha512-AF0YnJiXDMS3HQ2ugZcwJRBkspvNtlXk992IwTNFZxbZdinpPoVmPnDnHuekACcQY5bFRgHjMB2/o/GKkdLpWg==}
engines: {node: ^18.12.0} engines: {node: ^18.12.0}
dependencies: dependencies:
'@silverhand/essentials': 2.7.0 '@silverhand/essentials': 2.7.0
'@withtyped/server': 0.12.5(zod@3.20.2) '@withtyped/server': 0.12.7(zod@3.20.2)
transitivePeerDependencies: transitivePeerDependencies:
- zod - zod
dev: true dev: true
@ -9848,15 +9848,6 @@ packages:
eslint-visitor-keys: 3.4.1 eslint-visitor-keys: 3.4.1
dev: true 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): /@withtyped/client@0.7.19(zod@3.20.2):
resolution: {integrity: sha512-cHmqCIsEMonrE9kDbu8YAPWWPHmy6imUGbUWQ2PGaPLTJagbgH3ABsxgnLiM0lm0LeuZFd3BHh+JYKRDUmS8cw==} resolution: {integrity: sha512-cHmqCIsEMonrE9kDbu8YAPWWPHmy6imUGbUWQ2PGaPLTJagbgH3ABsxgnLiM0lm0LeuZFd3BHh+JYKRDUmS8cw==}
dependencies: dependencies:
@ -9864,16 +9855,6 @@ packages:
'@withtyped/shared': 0.2.2 '@withtyped/shared': 0.2.2
transitivePeerDependencies: transitivePeerDependencies:
- zod - 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): /@withtyped/server@0.12.7(zod@3.20.2):
resolution: {integrity: sha512-NNT78ZZmSZiEosxI3iW/kVx1KEG5vetvpEXNl0Gy58OlOnI8l/7h8Q//JZJ268xWOKyaNI4KrngTRtL5uvZu9Q==} resolution: {integrity: sha512-NNT78ZZmSZiEosxI3iW/kVx1KEG5vetvpEXNl0Gy58OlOnI8l/7h8Q//JZJ268xWOKyaNI4KrngTRtL5uvZu9Q==}