From cec08acb52e077977e9cfa5ec4a6dc428008a126 Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Thu, 8 Aug 2024 16:00:32 +0800 Subject: [PATCH] feat: report subscription usage updates (#6419) * feat: report subscription usage updates * refactor: refactor code according to CR --- packages/core/package.json | 2 +- packages/core/src/libraries/quota.ts | 51 ++++++++++++++++--- .../core/src/middleware/koa-quota-guard.ts | 16 ++++++ .../src/routes/applications/application.ts | 9 ++++ packages/core/src/routes/hook.ts | 15 +++++- .../src/routes/organization-role/index.ts | 16 ++++-- .../src/routes/organization-scope/index.ts | 16 ++++-- .../core/src/routes/organization/index.ts | 17 +++++-- packages/core/src/routes/resource.ts | 15 +++++- .../src/routes/sign-in-experience/index.ts | 4 +- .../core/src/routes/sso-connector/index.ts | 15 +++++- packages/core/src/test-utils/quota.ts | 1 + packages/core/src/utils/subscription/index.ts | 38 ++++++++++++-- packages/core/src/utils/subscription/types.ts | 18 +++++++ pnpm-lock.yaml | 15 +++++- 15 files changed, 222 insertions(+), 26 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index a78343fd4..87d1f9473 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -95,7 +95,7 @@ "zod": "^3.23.8" }, "devDependencies": { - "@logto/cloud": "0.2.5-3b703da", + "@logto/cloud": "0.2.5-50ff8fe", "@silverhand/eslint-config": "6.0.1", "@silverhand/ts-config": "6.0.0", "@types/adm-zip": "^0.5.5", diff --git a/packages/core/src/libraries/quota.ts b/packages/core/src/libraries/quota.ts index d4da96ab4..f3338877a 100644 --- a/packages/core/src/libraries/quota.ts +++ b/packages/core/src/libraries/quota.ts @@ -7,8 +7,10 @@ import type Queries from '#src/tenants/Queries.js'; import assertThat from '#src/utils/assert-that.js'; import { getTenantSubscriptionPlan, - getTenantSubscriptionQuotaAndUsage, + getTenantSubscriptionData, getTenantSubscriptionScopeUsage, + reportSubscriptionUpdates, + isReportSubscriptionUpdatesUsageKey, } from '#src/utils/subscription/index.js'; import { type SubscriptionQuota, type FeatureQuota } from '#src/utils/subscription/types.js'; @@ -21,6 +23,11 @@ const notNumber = (): never => { throw new Error('Only support usage query for numeric quota'); }; +const shouldReportSubscriptionUpdates = (planId: string, key: keyof SubscriptionQuota): boolean => + EnvSet.values.isDevFeaturesEnabled && + planId === ReservedPlanId.Pro && + isReportSubscriptionUpdatesUsageKey(key); + export const createQuotaLibrary = ( queries: Queries, cloudConnection: CloudConnectionLibrary, @@ -156,9 +163,16 @@ export const createQuotaLibrary = ( return; } - const { quota: fullQuota, usage: fullUsage } = await getTenantSubscriptionQuotaAndUsage( - cloudConnection - ); + const { + planId, + quota: fullQuota, + usage: fullUsage, + } = await getTenantSubscriptionData(cloudConnection); + + // Do not block Pro plan from adding add-on resources. + if (shouldReportSubscriptionUpdates(planId, key)) { + return; + } // Type `SubscriptionQuota` and type `SubscriptionUsage` are sharing keys, this design helps us to compare the usage with the quota limit in a easier way. const { [key]: limit } = fullQuota; @@ -227,7 +241,7 @@ export const createQuotaLibrary = ( }, scopeUsages, ] = await Promise.all([ - getTenantSubscriptionQuotaAndUsage(cloudConnection), + getTenantSubscriptionData(cloudConnection), getTenantSubscriptionScopeUsage(cloudConnection, entityName), ]); const usage = scopeUsages[entityId] ?? 0; @@ -262,5 +276,30 @@ export const createQuotaLibrary = ( ); }; - return { guardKey, guardTenantUsageByKey, guardEntityScopesUsage }; + const reportSubscriptionUpdatesUsage = async (key: keyof SubscriptionQuota) => { + const { isCloud, isIntegrationTest } = EnvSet.values; + + // Cloud only feature, skip in non-cloud environments + if (!isCloud) { + return; + } + + // Disable in integration tests + if (isIntegrationTest) { + return; + } + + const { planId } = await getTenantSubscriptionData(cloudConnection); + + if (shouldReportSubscriptionUpdates(planId, key)) { + await reportSubscriptionUpdates(cloudConnection, key); + } + }; + + return { + guardKey, + guardTenantUsageByKey, + guardEntityScopesUsage, + reportSubscriptionUpdatesUsage, + }; }; diff --git a/packages/core/src/middleware/koa-quota-guard.ts b/packages/core/src/middleware/koa-quota-guard.ts index e17f875e7..8daba2154 100644 --- a/packages/core/src/middleware/koa-quota-guard.ts +++ b/packages/core/src/middleware/koa-quota-guard.ts @@ -1,3 +1,4 @@ +import { type Nullable } from '@silverhand/essentials'; import type { MiddlewareType } from 'koa'; import { type QuotaLibrary } from '#src/libraries/quota.js'; @@ -45,3 +46,18 @@ export function newKoaQuotaGuard({ return next(); }; } + +export function koaReportSubscriptionUpdates({ + key, + quota, + methods = ['POST', 'PUT', 'DELETE'], +}: NewUsageGuardConfig): MiddlewareType> { + return async (ctx, next) => { + await next(); + + // eslint-disable-next-line no-restricted-syntax + if (methods.includes(ctx.method.toUpperCase() as Method)) { + await quota.reportSubscriptionUpdatesUsage(key); + } + }; +} diff --git a/packages/core/src/routes/applications/application.ts b/packages/core/src/routes/applications/application.ts index f0adf3adc..bb78e8397 100644 --- a/packages/core/src/routes/applications/application.ts +++ b/packages/core/src/routes/applications/application.ts @@ -213,6 +213,11 @@ export default function applicationRoutes( } ctx.body = application; + + if (rest.type === ApplicationType.MachineToMachine) { + await quota.reportSubscriptionUpdatesUsage('machineToMachineLimit'); + } + return next(); } ); @@ -356,6 +361,10 @@ export default function applicationRoutes( await queries.applications.deleteApplicationById(id); ctx.status = 204; + if (type === ApplicationType.MachineToMachine) { + await quota.reportSubscriptionUpdatesUsage('machineToMachineLimit'); + } + return next(); } ); diff --git a/packages/core/src/routes/hook.ts b/packages/core/src/routes/hook.ts index e3dc0920b..a6298d42c 100644 --- a/packages/core/src/routes/hook.ts +++ b/packages/core/src/routes/hook.ts @@ -18,7 +18,10 @@ 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 koaPagination from '#src/middleware/koa-pagination.js'; -import koaQuotaGuard, { newKoaQuotaGuard } from '#src/middleware/koa-quota-guard.js'; +import koaQuotaGuard, { + koaReportSubscriptionUpdates, + newKoaQuotaGuard, +} from '#src/middleware/koa-quota-guard.js'; import { type AllowedKeyPrefix } from '#src/queries/log.js'; import assertThat from '#src/utils/assert-that.js'; @@ -169,6 +172,11 @@ export default function hookRoutes( response: Hooks.guard, status: [201, 400], }), + koaReportSubscriptionUpdates({ + key: 'hooksLimit', + quota, + methods: ['POST'], + }), async (ctx, next) => { const { event, events, enabled, ...rest } = ctx.guard.body; assertThat(events ?? event, new RequestError({ code: 'hook.missing_events', status: 400 })); @@ -257,6 +265,11 @@ export default function hookRoutes( router.delete( '/hooks/:id', koaGuard({ params: z.object({ id: z.string() }), status: [204, 404] }), + koaReportSubscriptionUpdates({ + key: 'hooksLimit', + quota, + methods: ['DELETE'], + }), async (ctx, next) => { const { id } = ctx.guard.params; await deleteHookById(id); diff --git a/packages/core/src/routes/organization-role/index.ts b/packages/core/src/routes/organization-role/index.ts index 84fef012c..00636750d 100644 --- a/packages/core/src/routes/organization-role/index.ts +++ b/packages/core/src/routes/organization-role/index.ts @@ -6,13 +6,17 @@ import { type OrganizationRoleKeys, } from '@logto/schemas'; import { generateStandardId } from '@logto/shared'; +import { condArray } from '@silverhand/essentials'; import { z } from 'zod'; import { EnvSet } from '#src/env-set/index.js'; import { buildManagementApiContext } from '#src/libraries/hook/utils.js'; import koaGuard from '#src/middleware/koa-guard.js'; import koaPagination from '#src/middleware/koa-pagination.js'; -import koaQuotaGuard, { newKoaQuotaGuard } from '#src/middleware/koa-quota-guard.js'; +import koaQuotaGuard, { + koaReportSubscriptionUpdates, + newKoaQuotaGuard, +} from '#src/middleware/koa-quota-guard.js'; import { organizationRoleSearchKeys } from '#src/queries/organization/index.js'; import SchemaRouter from '#src/utils/SchemaRouter.js'; import { parseSearchOptions } from '#src/utils/search.js'; @@ -45,11 +49,17 @@ export default function organizationRoleRoutes( unknown, ManagementApiRouterContext >(OrganizationRoles, roles, { - middlewares: [ + middlewares: condArray( EnvSet.values.isDevFeaturesEnabled ? newKoaQuotaGuard({ key: 'organizationsEnabled', quota, methods: ['POST', 'PUT'] }) : koaQuotaGuard({ key: 'organizationsEnabled', quota, methods: ['POST', 'PUT'] }), - ], + EnvSet.values.isDevFeaturesEnabled && + koaReportSubscriptionUpdates({ + key: 'organizationsEnabled', + quota, + methods: ['POST', 'PUT', 'DELETE'], + }) + ), disabled: { get: true, post: true }, errorHandler, searchFields: ['name'], diff --git a/packages/core/src/routes/organization-scope/index.ts b/packages/core/src/routes/organization-scope/index.ts index 5d40fc6c5..1c1f321fc 100644 --- a/packages/core/src/routes/organization-scope/index.ts +++ b/packages/core/src/routes/organization-scope/index.ts @@ -1,7 +1,11 @@ import { OrganizationScopes } from '@logto/schemas'; +import { condArray } from '@silverhand/essentials'; import { EnvSet } from '#src/env-set/index.js'; -import koaQuotaGuard, { newKoaQuotaGuard } from '#src/middleware/koa-quota-guard.js'; +import koaQuotaGuard, { + newKoaQuotaGuard, + koaReportSubscriptionUpdates, +} from '#src/middleware/koa-quota-guard.js'; import SchemaRouter from '#src/utils/SchemaRouter.js'; import { errorHandler } from '../organization/utils.js'; @@ -19,11 +23,17 @@ export default function organizationScopeRoutes( ]: RouterInitArgs ) { const router = new SchemaRouter(OrganizationScopes, scopes, { - middlewares: [ + middlewares: condArray( EnvSet.values.isDevFeaturesEnabled ? newKoaQuotaGuard({ key: 'organizationsEnabled', quota, methods: ['POST', 'PUT'] }) : koaQuotaGuard({ key: 'organizationsEnabled', quota, methods: ['POST', 'PUT'] }), - ], + EnvSet.values.isDevFeaturesEnabled && + koaReportSubscriptionUpdates({ + key: 'organizationsEnabled', + quota, + methods: ['POST', 'PUT', 'DELETE'], + }) + ), errorHandler, searchFields: ['name'], }); diff --git a/packages/core/src/routes/organization/index.ts b/packages/core/src/routes/organization/index.ts index b548485a7..045f3c2d3 100644 --- a/packages/core/src/routes/organization/index.ts +++ b/packages/core/src/routes/organization/index.ts @@ -1,11 +1,14 @@ import { type OrganizationWithFeatured, Organizations, featuredUserGuard } from '@logto/schemas'; -import { yes } from '@silverhand/essentials'; +import { condArray, yes } from '@silverhand/essentials'; import { z } from 'zod'; import { EnvSet } from '#src/env-set/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; import koaPagination from '#src/middleware/koa-pagination.js'; -import koaQuotaGuard, { newKoaQuotaGuard } from '#src/middleware/koa-quota-guard.js'; +import koaQuotaGuard, { + newKoaQuotaGuard, + koaReportSubscriptionUpdates, +} from '#src/middleware/koa-quota-guard.js'; import SchemaRouter from '#src/utils/SchemaRouter.js'; import { parseSearchOptions } from '#src/utils/search.js'; @@ -31,11 +34,17 @@ export default function organizationRoutes( ] = args; const router = new SchemaRouter(Organizations, organizations, { - middlewares: [ + middlewares: condArray( EnvSet.values.isDevFeaturesEnabled ? newKoaQuotaGuard({ key: 'organizationsEnabled', quota, methods: ['POST', 'PUT'] }) : koaQuotaGuard({ key: 'organizationsEnabled', quota, methods: ['POST', 'PUT'] }), - ], + EnvSet.values.isDevFeaturesEnabled && + koaReportSubscriptionUpdates({ + key: 'organizationsEnabled', + quota, + methods: ['POST', 'PUT', 'DELETE'], + }) + ), errorHandler, searchFields: ['name'], disabled: { get: true }, diff --git a/packages/core/src/routes/resource.ts b/packages/core/src/routes/resource.ts index 1553cb5c7..486a703d8 100644 --- a/packages/core/src/routes/resource.ts +++ b/packages/core/src/routes/resource.ts @@ -7,7 +7,10 @@ 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 koaPagination from '#src/middleware/koa-pagination.js'; -import koaQuotaGuard, { newKoaQuotaGuard } from '#src/middleware/koa-quota-guard.js'; +import koaQuotaGuard, { + newKoaQuotaGuard, + koaReportSubscriptionUpdates, +} from '#src/middleware/koa-quota-guard.js'; import assertThat from '#src/utils/assert-that.js'; import { attachScopesToResources } from '#src/utils/resource.js'; @@ -87,6 +90,11 @@ export default function resourceRoutes( response: Resources.guard.extend({ scopes: Scopes.guard.array().optional() }), status: [201, 422], }), + koaReportSubscriptionUpdates({ + key: 'resourcesLimit', + quota, + methods: ['POST'], + }), async (ctx, next) => { const { body } = ctx.guard; const { indicator } = body; @@ -184,6 +192,11 @@ export default function resourceRoutes( router.delete( '/resources/:id', koaGuard({ params: object({ id: string().min(1) }), status: [204, 400, 404] }), + koaReportSubscriptionUpdates({ + key: 'resourcesLimit', + quota, + methods: ['DELETE'], + }), async (ctx, next) => { const { id } = ctx.guard.params; diff --git a/packages/core/src/routes/sign-in-experience/index.ts b/packages/core/src/routes/sign-in-experience/index.ts index b698da24a..f537e7b42 100644 --- a/packages/core/src/routes/sign-in-experience/index.ts +++ b/packages/core/src/routes/sign-in-experience/index.ts @@ -19,7 +19,7 @@ export default function signInExperiencesRoutes( const { deleteConnectorById } = queries.connectors; const { signInExperiences: { validateLanguageInfo }, - quota: { guardKey, guardTenantUsageByKey }, + quota: { guardKey, guardTenantUsageByKey, reportSubscriptionUpdatesUsage }, } = libraries; const { getLogtoConnectors } = connectors; @@ -122,6 +122,8 @@ export default function signInExperiencesRoutes( : rest ); + await reportSubscriptionUpdatesUsage('mfaEnabled'); + return next(); } ); diff --git a/packages/core/src/routes/sso-connector/index.ts b/packages/core/src/routes/sso-connector/index.ts index cf611fbc6..b12e5372b 100644 --- a/packages/core/src/routes/sso-connector/index.ts +++ b/packages/core/src/routes/sso-connector/index.ts @@ -11,7 +11,10 @@ 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 koaPagination from '#src/middleware/koa-pagination.js'; -import koaQuotaGuard, { newKoaQuotaGuard } from '#src/middleware/koa-quota-guard.js'; +import koaQuotaGuard, { + koaReportSubscriptionUpdates, + newKoaQuotaGuard, +} from '#src/middleware/koa-quota-guard.js'; import { ssoConnectorCreateGuard, ssoConnectorPatchGuard } from '#src/routes/sso-connector/type.js'; import { ssoConnectorFactories } from '#src/sso/index.js'; import { isSupportedSsoConnector, isSupportedSsoProvider } from '#src/sso/utils.js'; @@ -77,6 +80,11 @@ export default function singleSignOnConnectorsRoutes { const { body } = ctx.guard; const { providerName, connectorName, config, domains, ...rest } = body; @@ -202,6 +210,11 @@ export default function singleSignOnConnectorsRoutes { const { id } = ctx.guard.params; diff --git a/packages/core/src/test-utils/quota.ts b/packages/core/src/test-utils/quota.ts index d995f17b9..81386bbfb 100644 --- a/packages/core/src/test-utils/quota.ts +++ b/packages/core/src/test-utils/quota.ts @@ -7,5 +7,6 @@ export const createMockQuotaLibrary = (): QuotaLibrary => { guardKey: jest.fn(), guardTenantUsageByKey: jest.fn(), guardEntityScopesUsage: jest.fn(), + reportSubscriptionUpdatesUsage: jest.fn(), }; }; diff --git a/packages/core/src/utils/subscription/index.ts b/packages/core/src/utils/subscription/index.ts index c6e701b62..303cb8a95 100644 --- a/packages/core/src/utils/subscription/index.ts +++ b/packages/core/src/utils/subscription/index.ts @@ -1,3 +1,5 @@ +import { trySafe } from '@silverhand/essentials'; + import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js'; import assertThat from '../assert-that.js'; @@ -7,6 +9,8 @@ import { type SubscriptionUsage, type SubscriptionPlan, type Subscription, + type ReportSubscriptionUpdatesUsageKey, + allReportSubscriptionUpdatesUsageKeys, } from './types.js'; export const getTenantSubscription = async ( @@ -33,19 +37,21 @@ export const getTenantSubscriptionPlan = async ( return plan; }; -export const getTenantSubscriptionQuotaAndUsage = async ( +export const getTenantSubscriptionData = async ( cloudConnection: CloudConnectionLibrary ): Promise<{ + planId: string; quota: SubscriptionQuota; usage: SubscriptionUsage; }> => { const client = await cloudConnection.getClient(); - const [quota, usage] = await Promise.all([ + const [{ planId }, quota, usage] = await Promise.all([ + client.get('/api/tenants/my/subscription'), client.get('/api/tenants/my/subscription/quota'), client.get('/api/tenants/my/subscription/usage'), ]); - return { quota, usage }; + return { planId, quota, usage }; }; export const getTenantSubscriptionScopeUsage = async ( @@ -60,3 +66,29 @@ export const getTenantSubscriptionScopeUsage = async ( return scopeUsages; }; + +export const reportSubscriptionUpdates = async ( + cloudConnection: CloudConnectionLibrary, + usageKey: keyof SubscriptionQuota +): Promise => { + if (!isReportSubscriptionUpdatesUsageKey(usageKey)) { + return; + } + + const client = await cloudConnection.getClient(); + // We only report to the Cloud to notify the resource usage updates, and do not care the response. We will see error logs on the Cloud side if there is any issue. + await trySafe( + client.post('/api/tenants/my/subscription/item-updates', { + body: { + usageKey, + }, + }) + ); +}; + +export const isReportSubscriptionUpdatesUsageKey = ( + value: string +): value is ReportSubscriptionUpdatesUsageKey => { + // eslint-disable-next-line no-restricted-syntax + return allReportSubscriptionUpdatesUsageKeys.includes(value as ReportSubscriptionUpdatesUsageKey); +}; diff --git a/packages/core/src/utils/subscription/types.ts b/packages/core/src/utils/subscription/types.ts index e1ae1b6d1..74f9f138b 100644 --- a/packages/core/src/utils/subscription/types.ts +++ b/packages/core/src/utils/subscription/types.ts @@ -3,9 +3,12 @@ import { type RouterRoutes } from '@withtyped/client'; import { type z, type ZodType } from 'zod'; type GetRoutes = RouterRoutes['get']; +type PostRoutes = RouterRoutes['post']; type RouteResponseType = z.infer>; +type RouteRequestBodyType = + z.infer>; export type SubscriptionPlan = RouteResponseType[number]; @@ -37,3 +40,18 @@ export type SubscriptionQuota = Omit< export type SubscriptionUsage = RouteResponseType< GetRoutes['/api/tenants/:tenantId/subscription/usage'] >; + +export type ReportSubscriptionUpdatesUsageKey = RouteRequestBodyType< + PostRoutes['/api/tenants/my/subscription/item-updates'] +>['usageKey']; + +// Have to manually define this variable since we can only get the literal union from the @logto/cloud/routes module. +export const allReportSubscriptionUpdatesUsageKeys = Object.freeze([ + 'tokenLimit', + 'machineToMachineLimit', + 'resourcesLimit', + 'mfaEnabled', + 'organizationsEnabled', + 'tenantMembersLimit', + 'enterpriseSsoLimit', +]) satisfies readonly ReportSubscriptionUpdatesUsageKey[]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25d58808c..bc96c207e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2890,8 +2890,8 @@ importers: version: 3.23.8 devDependencies: '@logto/cloud': - specifier: 0.2.5-3b703da - version: 0.2.5-3b703da(zod@3.23.8) + specifier: 0.2.5-50ff8fe + version: 0.2.5-50ff8fe(zod@3.23.8) '@silverhand/eslint-config': specifier: 6.0.1 version: 6.0.1(eslint@8.57.0)(prettier@3.0.0)(typescript@5.5.3) @@ -5022,6 +5022,10 @@ packages: resolution: {integrity: sha512-VCevQnxP5910s/cDYAxoJRim9iH1yN/La0HAlOP6FhVGtZofYwTTfT9AQXC+dZScgydpcFWo4k/6MYOFRtZCLg==} engines: {node: ^20.9.0} + '@logto/cloud@0.2.5-50ff8fe': + resolution: {integrity: sha512-EMIGnx3swILEcSvYsAlPg9E1srtPcZxHxVH+D/dTrg8ctHbRAJkFbeuQFhwHGvs1dfgULd9MKtaAkL2qckExMw==} + engines: {node: ^20.9.0} + '@logto/cloud@0.2.5-923c26f': resolution: {integrity: sha512-NAK9/T7HxEfE2djO6VTekMziOXH6NtbAzwumZcZo0bqIUDGiKlUvted/KY6iqpCdfFOF4aIyKp+pvlQIjj1T6Q==} engines: {node: ^20.9.0} @@ -14660,6 +14664,13 @@ snapshots: transitivePeerDependencies: - zod + '@logto/cloud@0.2.5-50ff8fe(zod@3.23.8)': + dependencies: + '@silverhand/essentials': 2.9.1 + '@withtyped/server': 0.13.6(zod@3.23.8) + transitivePeerDependencies: + - zod + '@logto/cloud@0.2.5-923c26f(zod@3.23.8)': dependencies: '@silverhand/essentials': 2.9.1