From 99f2bff096d8c3225ccb24895c7eb4126471f55a Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Tue, 30 Sep 2025 00:22:02 -0700 Subject: [PATCH 1/2] feat(core): init posthog --- packages/core/package.json | 1 + .../saml-application/saml-applications.ts | 6 +- .../src/libraries/saml-application/utils.ts | 2 +- packages/core/src/libraries/user.test.ts | 12 +- packages/core/src/libraries/user.ts | 34 ++++- packages/core/src/main.ts | 7 +- packages/core/src/routes/admin-user/basics.ts | 45 +++--- .../src/routes/applications/application.ts | 11 ++ packages/core/src/routes/connector/index.ts | 32 +++- packages/core/src/routes/domain.ts | 5 +- .../classes/libraries/provision-library.ts | 2 +- packages/core/src/routes/hook.ts | 7 +- .../interaction/actions/submit-interaction.ts | 5 +- .../interaction/utils/single-sign-on.ts | 2 +- .../src/routes/logto-config/jwt-customizer.ts | 4 + .../src/routes/organization-role/index.ts | 7 + packages/core/src/routes/resource.ts | 11 +- packages/core/src/routes/role.ts | 12 +- .../core/src/routes/saml-application/index.ts | 10 +- .../src/routes/sign-in-experience/index.ts | 14 +- .../core/src/routes/sso-connector/index.ts | 6 + packages/core/src/tenants/Libraries.ts | 2 +- packages/core/src/utils/SchemaRouter.ts | 26 ++-- packages/core/src/utils/posthog.ts | 51 +++++++ packages/schemas/src/consts/index.ts | 1 + packages/schemas/src/consts/product-event.ts | 64 ++++++++ packages/shared/src/node/env/GlobalValues.ts | 7 +- pnpm-lock.yaml | 144 +++++++++--------- 28 files changed, 395 insertions(+), 135 deletions(-) create mode 100644 packages/core/src/utils/posthog.ts create mode 100644 packages/schemas/src/consts/product-event.ts diff --git a/packages/core/package.json b/packages/core/package.json index 3931324d0859..5ef5449b110a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -88,6 +88,7 @@ "pg-protocol": "^1.6.0", "pkg-dir": "^8.0.0", "pluralize": "^8.0.0", + "posthog-node": "^5.9.2", "qrcode": "^1.5.3", "raw-body": "^3.0.0", "redis": "^4.6.14", diff --git a/packages/core/src/libraries/saml-application/saml-applications.ts b/packages/core/src/libraries/saml-application/saml-applications.ts index 9b5a20592997..18db0c4d2aff 100644 --- a/packages/core/src/libraries/saml-application/saml-applications.ts +++ b/packages/core/src/libraries/saml-application/saml-applications.ts @@ -17,7 +17,7 @@ import RequestError from '#src/errors/RequestError/index.js'; import type Queries from '#src/tenants/Queries.js'; import assertThat from '#src/utils/assert-that.js'; -import { ensembleSamlApplication, generateKeyPairAndCertificate } from './utils.js'; +import { assembleSamlApplication, generateKeyPairAndCertificate } from './utils.js'; const consoleLog = new ConsoleLog(chalk.magenta('SAML app custom domain')); @@ -75,7 +75,7 @@ export const createSamlApplicationsLibrary = (queries: Queries) => { const samlConfig = await findSamlApplicationConfigByApplicationId(application.id); - return ensembleSamlApplication({ application, samlConfig }); + return assembleSamlApplication({ application, samlConfig }); }; const updateSamlApplicationById = async ( @@ -112,7 +112,7 @@ export const createSamlApplicationsLibrary = (queries: Queries) => { : originalAppConfig, ]); - return ensembleSamlApplication({ + return assembleSamlApplication({ application: updatedApplication, samlConfig: upToDateSamlConfig, }); diff --git a/packages/core/src/libraries/saml-application/utils.ts b/packages/core/src/libraries/saml-application/utils.ts index d86ddbb8df59..4cb60a187305 100644 --- a/packages/core/src/libraries/saml-application/utils.ts +++ b/packages/core/src/libraries/saml-application/utils.ts @@ -123,7 +123,7 @@ export const calculateCertificateFingerprints = ( * - A record from the `applications` table with a `type` of `SAML` * - A record from the `saml_application_configs` table */ -export const ensembleSamlApplication = ({ +export const assembleSamlApplication = ({ application, samlConfig, }: { diff --git a/packages/core/src/libraries/user.test.ts b/packages/core/src/libraries/user.test.ts index fe5438990578..39b4f96ad696 100644 --- a/packages/core/src/libraries/user.test.ts +++ b/packages/core/src/libraries/user.test.ts @@ -1,4 +1,4 @@ -import { MfaFactor, UsersPasswordEncryptionMethod } from '@logto/schemas'; +import { defaultTenantId, MfaFactor, UsersPasswordEncryptionMethod } from '@logto/schemas'; import { createMockUtils } from '@logto/shared/esm'; import { mockResource, mockAdminUserRole, mockScope } from '#src/__mocks__/index.js'; @@ -48,7 +48,7 @@ const queries = new MockQueries({ }); describe('generateUserId()', () => { - const { generateUserId } = createUserLibrary(queries); + const { generateUserId } = createUserLibrary(defaultTenantId, queries); afterEach(() => { hasUserWithId.mockClear(); @@ -98,7 +98,7 @@ describe('encryptUserPassword()', () => { }); describe('verifyUserPassword()', () => { - const { verifyUserPassword } = createUserLibrary(queries); + const { verifyUserPassword } = createUserLibrary(defaultTenantId, queries); describe('Argon2i', () => { it('resolves when password is correct', async () => { @@ -235,7 +235,7 @@ describe('verifyUserPassword()', () => { }); describe('findUserScopesForResourceId()', () => { - const { findUserScopesForResourceIndicator } = createUserLibrary(queries); + const { findUserScopesForResourceIndicator } = createUserLibrary(defaultTenantId, queries); it('returns scopes that the user has access', async () => { await expect( @@ -245,7 +245,7 @@ describe('findUserScopesForResourceId()', () => { }); describe('findUserRoles()', () => { - const { findUserRoles } = createUserLibrary(queries); + const { findUserRoles } = createUserLibrary(defaultTenantId, queries); it('returns user roles', async () => { await expect(findUserRoles(mockUser.id)).resolves.toEqual([mockAdminUserRole]); @@ -254,7 +254,7 @@ describe('findUserRoles()', () => { describe('addUserMfaVerification()', () => { const createdAt = new Date().toISOString(); - const { addUserMfaVerification } = createUserLibrary(queries); + const { addUserMfaVerification } = createUserLibrary(defaultTenantId, queries); beforeAll(() => { jest.useFakeTimers(); diff --git a/packages/core/src/libraries/user.ts b/packages/core/src/libraries/user.ts index ab6f4ed90529..ea2cb1dd644a 100644 --- a/packages/core/src/libraries/user.ts +++ b/packages/core/src/libraries/user.ts @@ -1,5 +1,10 @@ import type { BindMfa, CreateUser, Scope, User } from '@logto/schemas'; -import { RoleType, UsersPasswordEncryptionMethod } from '@logto/schemas'; +import { + adminTenantId, + ProductEvent, + RoleType, + UsersPasswordEncryptionMethod, +} from '@logto/schemas'; import { generateStandardShortId, generateStandardId } from '@logto/shared'; import type { Nullable } from '@silverhand/essentials'; import { deduplicateByKey, condArray } from '@silverhand/essentials'; @@ -15,13 +20,15 @@ import assertThat from '#src/utils/assert-that.js'; import { legacyVerify } from '#src/utils/password.js'; import type { OmitAutoSetFields } from '#src/utils/sql.js'; +import { captureDeveloperEvent } from '../utils/posthog.js'; + import { convertBindMfaToMfaVerification, encryptUserPassword } from './user.utils.js'; export type InsertUserResult = [User]; export type UserLibrary = ReturnType; -export const createUserLibrary = (queries: Queries) => { +export const createUserLibrary = (tenantId: string, queries: Queries) => { const { pool, roles: { findDefaultRoles, findRolesByRoleNames, findRoleByRoleName, findRolesByRoleIds }, @@ -59,11 +66,22 @@ export const createUserLibrary = (queries: Queries) => { { retries, factor: 0 } // No need for exponential backoff ); + type InsertUserOptions = { + /** Additional role names to assign to the user upon creation. */ + roleNames?: string[]; + /** + * Whether the user is created via an interactive flow (e.g. sign up). + * @default false + */ + isInteractive?: boolean; + }; + const insertUser = async ( data: OmitAutoSetFields, - additionalRoleNames: string[] + options?: InsertUserOptions ): Promise => { - const roleNames = [...EnvSet.values.userDefaultRoleNames, ...additionalRoleNames]; + const { isInteractive = false } = options ?? {}; + const roleNames = [...EnvSet.values.userDefaultRoleNames, ...(options?.roleNames ?? [])]; const [parameterRoles, defaultRoles] = await Promise.all([ findRolesByRoleNames(roleNames), findDefaultRoles(RoleType.User), @@ -71,7 +89,7 @@ export const createUserLibrary = (queries: Queries) => { assertThat(parameterRoles.length === roleNames.length, 'role.default_role_missing'); - return pool.transaction(async (connection) => { + const result = await pool.transaction<[User]>(async (connection) => { const user = await insertUserQuery(data); const roles = deduplicateByKey([...parameterRoles, ...defaultRoles], 'id'); @@ -84,6 +102,12 @@ export const createUserLibrary = (queries: Queries) => { return [user]; }); + + if (tenantId === adminTenantId) { + captureDeveloperEvent(result[0].id, ProductEvent.DeveloperCreated, { isInteractive }); + } + + return result; }; const checkIdentifierCollision = async ( diff --git a/packages/core/src/main.ts b/packages/core/src/main.ts index 673fc7585654..17f037ca5791 100644 --- a/packages/core/src/main.ts +++ b/packages/core/src/main.ts @@ -12,6 +12,7 @@ import initI18n from './i18n/init.js'; import SystemContext from './tenants/SystemContext.js'; import { tenantPool } from './tenants/index.js'; import { loadConnectorFactories } from './utils/connectors/index.js'; +import { shutdownPostHog } from './utils/posthog.js'; const consoleLog = new ConsoleLog(chalk.magenta('index')); @@ -54,5 +55,9 @@ try { consoleLog.error('Error while initializing app:'); consoleLog.error(error); - void Promise.all([trySafe(tenantPool.endAll()), trySafe(redisCache.disconnect())]); + void Promise.all([ + trySafe(tenantPool.endAll()), + trySafe(redisCache.disconnect()), + shutdownPostHog(), + ]); } diff --git a/packages/core/src/routes/admin-user/basics.ts b/packages/core/src/routes/admin-user/basics.ts index b00671a6c9bf..fecac154e252 100644 --- a/packages/core/src/routes/admin-user/basics.ts +++ b/packages/core/src/routes/admin-user/basics.ts @@ -1,7 +1,9 @@ /* eslint-disable max-lines */ import { emailRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit'; import { + ProductEvent, UsersPasswordEncryptionMethod, + adminTenantId, jsonObjectGuard, userProfileGuard, userProfileResponseGuard, @@ -16,13 +18,14 @@ import koaGuard from '#src/middleware/koa-guard.js'; import assertThat from '#src/utils/assert-that.js'; import { parseLegacyPassword } from '../../utils/password.js'; +import { captureDeveloperEvent } from '../../utils/posthog.js'; import { transpileUserProfileResponse } from '../../utils/user.js'; import type { ManagementApiRouter, RouterInitArgs } from '../types.js'; export default function adminUserBasicsRoutes( ...args: RouterInitArgs ) { - const [router, { queries, libraries }] = args; + const [router, { queries, libraries, id: tenantId }] = args; const { users: { deleteUserById, @@ -201,26 +204,23 @@ export default function adminUserBasicsRoutes( const id = await generateUserId(); - const [user] = await insertUser( - { - id, - primaryEmail, - primaryPhone, - username, - name, - avatar, - ...conditional(customData && { customData }), - ...conditional(password && (await encryptUserPassword(password))), - ...conditional( - passwordDigest && { - passwordEncrypted: passwordDigest, - passwordEncryptionMethod: passwordAlgorithm, - } - ), - ...conditional(profile && { profile }), - }, - [] - ); + const [user] = await insertUser({ + id, + primaryEmail, + primaryPhone, + username, + name, + avatar, + ...conditional(customData && { customData }), + ...conditional(password && (await encryptUserPassword(password))), + ...conditional( + passwordDigest && { + passwordEncrypted: passwordDigest, + passwordEncryptionMethod: passwordAlgorithm, + } + ), + ...conditional(profile && { profile }), + }); ctx.body = transpileUserProfileResponse(user); return next(); @@ -379,6 +379,9 @@ export default function adminUserBasicsRoutes( await signOutUser(userId); await deleteUserById(userId); + if (tenantId === adminTenantId) { + captureDeveloperEvent(userId, ProductEvent.DeveloperDeleted); + } ctx.status = 204; // Manually trigger the `User.Deleted` hook since we need to send the user data in the payload diff --git a/packages/core/src/routes/applications/application.ts b/packages/core/src/routes/applications/application.ts index 5b2cf1037ef2..1a86fc7b2fa3 100644 --- a/packages/core/src/routes/applications/application.ts +++ b/packages/core/src/routes/applications/application.ts @@ -8,6 +8,7 @@ import { demoAppApplicationId, hasSecrets, InternalRole, + ProductEvent, } from '@logto/schemas'; import { generateStandardId, generateStandardSecret } from '@logto/shared'; import { conditional } from '@silverhand/essentials'; @@ -20,6 +21,7 @@ import { buildOidcClientMetadata } from '#src/oidc/utils.js'; import assertThat from '#src/utils/assert-that.js'; import { parseSearchParamsForSearch } from '#src/utils/search.js'; +import { captureEvent } from '../../utils/posthog.js'; import type { ManagementApiRouter, RouterInitArgs } from '../types.js'; import applicationCustomDataRoutes from './application-custom-data.js'; @@ -230,6 +232,10 @@ export default function applicationRoutes( void quota.reportSubscriptionUpdatesUsage('thirdPartyApplicationsLimit'); } + captureEvent(tenantId, ProductEvent.AppCreated, { + type: rest.type, + isThirdParty: rest.isThirdParty ?? false, + }); return next(); } ); @@ -392,6 +398,11 @@ export default function applicationRoutes( void quota.reportSubscriptionUpdatesUsage('thirdPartyApplicationsLimit'); } + captureEvent(tenantId, ProductEvent.AppDeleted, { + type, + isThirdParty, + }); + return next(); } ); diff --git a/packages/core/src/routes/connector/index.ts b/packages/core/src/routes/connector/index.ts index 9b686ea78bef..dcdd1640ffc5 100644 --- a/packages/core/src/routes/connector/index.ts +++ b/packages/core/src/routes/connector/index.ts @@ -1,7 +1,13 @@ import { type ConnectorFactory } from '@logto/cli/lib/connector/index.js'; import type router from '@logto/cloud/routes'; import { demoConnectorIds, validateConfig } from '@logto/connector-kit'; -import { Connectors, ConnectorType, connectorResponseGuard, type JsonObject } from '@logto/schemas'; +import { + Connectors, + ConnectorType, + connectorResponseGuard, + type JsonObject, + ProductEvent, +} from '@logto/schemas'; import { generateStandardShortId } from '@logto/shared'; import { conditional } from '@silverhand/essentials'; import cleanDeep from 'clean-deep'; @@ -16,6 +22,7 @@ import { buildExtraInfo } from '#src/utils/connectors/extra-information.js'; import { loadConnectorFactories, transpileLogtoConnector } from '#src/utils/connectors/index.js'; import { checkSocialConnectorTargetAndPlatformUniqueness } from '#src/utils/connectors/platform.js'; +import { captureEvent } from '../../utils/posthog.js'; import type { ManagementApiRouter, RouterInitArgs } from '../types.js'; import connectorAuthorizationUriRoutes from './authorization-uri.js'; @@ -32,6 +39,10 @@ const guardConnectorsQuota = async ( }; const passwordlessConnector = new Set([ConnectorType.Email, ConnectorType.Sms]); +const pickFactoryProperties = >(factory: T) => ({ + type: factory.type, + name: factory.metadata.name.en, +}); export default function connectorRoutes( ...[router, tenant]: RouterInitArgs @@ -181,6 +192,18 @@ export default function connectorRoutes( if (conflictingConnectorIds.length > 0) { await deleteConnectorByIds(conflictingConnectorIds); } + + captureEvent( + tenant.id, + ProductEvent.PasswordlessConnectorUpdated, + pickFactoryProperties(connectorFactory) + ); + } else { + captureEvent( + tenant.id, + ProductEvent.SocialConnectorCreated, + pickFactoryProperties(connectorFactory) + ); } const connector = await getLogtoConnectorById(insertConnectorId); @@ -347,6 +370,7 @@ export default function connectorRoutes( ); router.delete( + // eslint-disable-next-line max-lines -- refactor later '/connectors/:id', koaGuard({ params: object({ id: string().min(1) }), status: [204, 404] }), async (ctx, next) => { @@ -365,6 +389,11 @@ export default function connectorRoutes( if (connectorFactory?.type === ConnectorType.Social) { await removeUnavailableSocialConnectorTargets(); + captureEvent( + tenant.id, + ProductEvent.SocialConnectorDeleted, + pickFactoryProperties(connectorFactory) + ); } ctx.status = 204; @@ -376,5 +405,4 @@ export default function connectorRoutes( connectorConfigTestingRoutes(router, tenant); connectorAuthorizationUriRoutes(router, tenant); connectorFactoryRoutes(router, tenant); - // eslint-disable-next-line max-lines } diff --git a/packages/core/src/routes/domain.ts b/packages/core/src/routes/domain.ts index 6a4e11bd9ac4..f60de26b8c6a 100644 --- a/packages/core/src/routes/domain.ts +++ b/packages/core/src/routes/domain.ts @@ -1,4 +1,4 @@ -import { Domains, domainResponseGuard, domainSelectFields } from '@logto/schemas'; +import { Domains, ProductEvent, domainResponseGuard, domainSelectFields } from '@logto/schemas'; import { pick, trySafe } from '@silverhand/essentials'; import { z } from 'zod'; @@ -8,6 +8,7 @@ import koaGuard from '#src/middleware/koa-guard.js'; import assertThat from '#src/utils/assert-that.js'; import { EnvSet } from '../env-set/index.js'; +import { captureEvent } from '../utils/posthog.js'; import type { ManagementApiRouter, RouterInitArgs } from './types.js'; @@ -122,6 +123,7 @@ export default function domainRoutes( ctx.status = 201; ctx.body = pick(syncedDomain, ...domainSelectFields); + captureEvent(tenantId, ProductEvent.CustomDomainCreated); return next(); } ); @@ -143,6 +145,7 @@ export default function domainRoutes( ctx.status = 204; + captureEvent(tenantId, ProductEvent.CustomDomainDeleted); return next(); } ); diff --git a/packages/core/src/routes/experience/classes/libraries/provision-library.ts b/packages/core/src/routes/experience/classes/libraries/provision-library.ts index 8b4b62013ca5..77e6812f9742 100644 --- a/packages/core/src/routes/experience/classes/libraries/provision-library.ts +++ b/packages/core/src/routes/experience/classes/libraries/provision-library.ts @@ -83,7 +83,7 @@ export class ProvisionLibrary { ...conditional(socialIdentity && { identities: toUserSocialIdentityData(socialIdentity) }), ...conditional(customData && { customData }), }, - initialUserRoles + { roleNames: initialUserRoles, isInteractive: true } ); if (enterpriseSsoIdentity) { diff --git a/packages/core/src/routes/hook.ts b/packages/core/src/routes/hook.ts index 5832247bd0e3..85595f04bfd3 100644 --- a/packages/core/src/routes/hook.ts +++ b/packages/core/src/routes/hook.ts @@ -1,6 +1,7 @@ import { Hooks, Logs, + ProductEvent, hook, hookConfigGuard, hookEventGuard, @@ -21,6 +22,8 @@ import { koaReportSubscriptionUpdates, koaQuotaGuard } from '#src/middleware/koa import { type AllowedKeyPrefix } from '#src/queries/log.js'; import assertThat from '#src/utils/assert-that.js'; +import { captureEvent } from '../utils/posthog.js'; + import type { ManagementApiRouter, RouterInitArgs } from './types.js'; const nonemptyUniqueHookEventsGuard = hookEventsGuard @@ -28,7 +31,7 @@ const nonemptyUniqueHookEventsGuard = hookEventsGuard .transform((events) => deduplicate(events)); export default function hookRoutes( - ...[router, { queries, libraries }]: RouterInitArgs + ...[router, { id: tenantId, queries, libraries }]: RouterInitArgs ) { const { hooks: { @@ -186,6 +189,7 @@ export default function hookRoutes( ctx.status = 201; + captureEvent(tenantId, ProductEvent.WebhookCreated); return next(); } ); @@ -269,6 +273,7 @@ export default function hookRoutes( await deleteHookById(id); ctx.status = 204; + captureEvent(tenantId, ProductEvent.WebhookDeleted); return next(); } ); diff --git a/packages/core/src/routes/interaction/actions/submit-interaction.ts b/packages/core/src/routes/interaction/actions/submit-interaction.ts index 6af01351d3c6..21ea94550bff 100644 --- a/packages/core/src/routes/interaction/actions/submit-interaction.ts +++ b/packages/core/src/routes/interaction/actions/submit-interaction.ts @@ -165,7 +165,10 @@ async function handleSubmitRegister( } ), }, - getInitialUserRoles(isInAdminTenant, isCreatingFirstAdminUser, isCloud) + { + roleNames: getInitialUserRoles(isInAdminTenant, isCreatingFirstAdminUser, isCloud), + isInteractive: true, + } ); if (isCreatingFirstAdminUser) { diff --git a/packages/core/src/routes/interaction/utils/single-sign-on.ts b/packages/core/src/routes/interaction/utils/single-sign-on.ts index 34c5570f33bb..b50bc4a075dd 100644 --- a/packages/core/src/routes/interaction/utils/single-sign-on.ts +++ b/packages/core/src/routes/interaction/utils/single-sign-on.ts @@ -440,7 +440,7 @@ export const registerWithSsoAuthentication = async ( ...syncingProfile, lastSignInAt: Date.now(), }, - [] + { isInteractive: true } ); const { id: userId } = user; diff --git a/packages/core/src/routes/logto-config/jwt-customizer.ts b/packages/core/src/routes/logto-config/jwt-customizer.ts index e280fde60e57..9fd01b736bf6 100644 --- a/packages/core/src/routes/logto-config/jwt-customizer.ts +++ b/packages/core/src/routes/logto-config/jwt-customizer.ts @@ -2,6 +2,7 @@ import { CustomJwtErrorCode, LogtoJwtTokenKey, LogtoJwtTokenKeyType, + ProductEvent, accessTokenJwtCustomizerGuard, adminTenantId, clientCredentialsJwtCustomizerGuard, @@ -21,6 +22,7 @@ import { koaQuotaGuard } from '#src/middleware/koa-quota-guard.js'; import { getConsoleLogFromContext } from '#src/utils/console.js'; import { parseCustomJwtResponseError } from '#src/utils/custom-jwt/index.js'; +import { captureEvent } from '../../utils/posthog.js'; import type { ManagementApiRouter, RouterInitArgs } from '../types.js'; const getJwtTokenKeyAndBody = (tokenPath: LogtoJwtTokenKeyType, body: unknown) => { @@ -100,6 +102,7 @@ export default function logtoConfigJwtCustomizerRoutes( ...[ originalRouter, { + id: tenantId, queries: { organizations: { roles, @@ -46,6 +49,9 @@ export default function organizationRoleRoutes( disabled: { get: true, post: true }, errorHandler, searchFields: ['name'], + hooks: { + afterDelete: () => captureEvent(tenantId, ProductEvent.OrganizationRoleDeleted), + }, }); router.get( @@ -123,6 +129,7 @@ export default function organizationRoleRoutes( }); } + captureEvent(tenantId, ProductEvent.OrganizationRoleCreated); return next(); } ); diff --git a/packages/core/src/routes/resource.ts b/packages/core/src/routes/resource.ts index 262f3045866a..b64280b5cb72 100644 --- a/packages/core/src/routes/resource.ts +++ b/packages/core/src/routes/resource.ts @@ -1,4 +1,4 @@ -import { isManagementApi, Resources, Scopes } from '@logto/schemas'; +import { isManagementApi, ProductEvent, Resources, Scopes } from '@logto/schemas'; import { generateStandardId } from '@logto/shared'; import { yes } from '@silverhand/essentials'; import { boolean, object, string } from 'zod'; @@ -10,12 +10,15 @@ import { koaQuotaGuard, koaReportSubscriptionUpdates } from '#src/middleware/koa import assertThat from '#src/utils/assert-that.js'; import { attachScopesToResources } from '#src/utils/resource.js'; +import { captureEvent } from '../utils/posthog.js'; + import type { ManagementApiRouter, RouterInitArgs } from './types.js'; export default function resourceRoutes( ...[ router, { + id: tenantId, queries, libraries: { quota }, }, @@ -110,6 +113,7 @@ export default function resourceRoutes( ctx.status = 201; ctx.body = { ...resource, scopes: [] }; + captureEvent(tenantId, ProductEvent.ApiResourceCreated); return next(); } ); @@ -193,16 +197,17 @@ export default function resourceRoutes( }), async (ctx, next) => { const { id } = ctx.guard.params; - const { indicator } = await findResourceById(id); + assertThat( !isManagementApi(indicator), new RequestError({ code: 'resource.cannot_delete_management_api' }) ); - await deleteResourceById(id); + ctx.status = 204; + captureEvent(tenantId, ProductEvent.ApiResourceDeleted); return next(); } ); diff --git a/packages/core/src/routes/role.ts b/packages/core/src/routes/role.ts index cabb46b3ab0d..55d831584aa3 100644 --- a/packages/core/src/routes/role.ts +++ b/packages/core/src/routes/role.ts @@ -1,5 +1,11 @@ import type { RoleResponse } from '@logto/schemas'; -import { RoleType, Roles, featuredApplicationGuard, featuredUserGuard } from '@logto/schemas'; +import { + ProductEvent, + RoleType, + Roles, + featuredApplicationGuard, + featuredUserGuard, +} from '@logto/schemas'; import { generateStandardId } from '@logto/shared'; import { pickState, trySafe, tryThat } from '@silverhand/essentials'; import { number, object, string, z } from 'zod'; @@ -12,6 +18,8 @@ import koaRoleRlsErrorHandler from '#src/middleware/koa-role-rls-error-handler.j import assertThat from '#src/utils/assert-that.js'; import { parseSearchParamsForSearch } from '#src/utils/search.js'; +import { captureEvent } from '../utils/posthog.js'; + import roleApplicationRoutes from './role.application.js'; import roleUserRoutes from './role.user.js'; import type { ManagementApiRouter, RouterInitArgs } from './types.js'; @@ -200,6 +208,7 @@ export default function roleRoutes( }); } + captureEvent(tenant.id, ProductEvent.RoleCreated, { type: role.type }); return next(); } ); @@ -274,6 +283,7 @@ export default function roleRoutes( ctx.status = 204; + captureEvent(tenant.id, ProductEvent.RoleDeleted, { type: role.type }); return next(); } ); diff --git a/packages/core/src/routes/saml-application/index.ts b/packages/core/src/routes/saml-application/index.ts index 17b2f97bff2e..11b5b810920e 100644 --- a/packages/core/src/routes/saml-application/index.ts +++ b/packages/core/src/routes/saml-application/index.ts @@ -1,5 +1,6 @@ import { ApplicationType, + ProductEvent, samlApplicationCreateGuard, samlApplicationPatchGuard, samlApplicationResponseGuard, @@ -14,7 +15,7 @@ import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import { calculateCertificateFingerprints, - ensembleSamlApplication, + assembleSamlApplication, validateAcsUrl, } from '#src/libraries/saml-application/utils.js'; import koaGuard from '#src/middleware/koa-guard.js'; @@ -26,6 +27,8 @@ import { getSamlAppCallbackUrl } from '#src/saml-application/SamlApplication/uti import assertThat from '#src/utils/assert-that.js'; import { parseSearchParamsForSearch } from '#src/utils/search.js'; +import { captureEvent } from '../../utils/posthog.js'; + export default function samlApplicationRoutes( ...[router, { id: tenantId, queries, libraries }]: RouterInitArgs ) { @@ -131,12 +134,13 @@ export default function samlApplicationRoutes( void quota.reportSubscriptionUpdatesUsage('samlApplicationsLimit'); ctx.status = 201; - ctx.body = ensembleSamlApplication({ application, samlConfig }); + ctx.body = assembleSamlApplication({ application, samlConfig }); } catch (error) { await deleteApplicationById(application.id); throw error; } + captureEvent(tenantId, ProductEvent.AppCreated, { type: ApplicationType.SAML }); return next(); } ); @@ -201,11 +205,11 @@ export default function samlApplicationRoutes( ); await deleteApplicationById(id); - void quota.reportSubscriptionUpdatesUsage('samlApplicationsLimit'); ctx.status = 204; + captureEvent(tenantId, ProductEvent.AppDeleted, { type: ApplicationType.SAML }); return next(); } ); diff --git a/packages/core/src/routes/sign-in-experience/index.ts b/packages/core/src/routes/sign-in-experience/index.ts index 80df43fc1ff0..ed905597bca0 100644 --- a/packages/core/src/routes/sign-in-experience/index.ts +++ b/packages/core/src/routes/sign-in-experience/index.ts @@ -1,6 +1,11 @@ import { DemoConnector } from '@logto/connector-kit'; import { PasswordPolicyChecker } from '@logto/core-kit'; -import { ConnectorType, SignInExperiences, ForgotPasswordMethod } from '@logto/schemas'; +import { + ConnectorType, + SignInExperiences, + ForgotPasswordMethod, + ProductEvent, +} from '@logto/schemas'; import { conditional, tryThat } from '@silverhand/essentials'; import { literal, object, string, z } from 'zod'; @@ -15,6 +20,7 @@ import koaGuard from '#src/middleware/koa-guard.js'; import RequestError from '../../errors/RequestError/index.js'; import { checkPasswordPolicyForUser } from '../../utils/password.js'; +import { captureEvent } from '../../utils/posthog.js'; import type { ManagementApiRouter, RouterInitArgs } from '../types.js'; import customUiAssetsRoutes from './custom-ui-assets/index.js'; @@ -22,7 +28,7 @@ import customUiAssetsRoutes from './custom-ui-assets/index.js'; export default function signInExperiencesRoutes( ...args: RouterInitArgs ) { - const [router, { queries, libraries, connectors }] = args; + const [router, { id: tenantId, queries, libraries, connectors }] = args; const { findDefaultSignInExperience, updateDefaultSignInExperience } = queries.signInExperiences; const { deleteConnectorById } = queries.connectors; const { findUserById } = queries.users; @@ -197,6 +203,10 @@ export default function signInExperiencesRoutes( void quota.reportSubscriptionUpdatesUsage('securityFeaturesEnabled'); } + captureEvent( + tenantId, + mfa?.factors.length ? ProductEvent.MfaEnabled : ProductEvent.MfaDisabled + ); return next(); } ); diff --git a/packages/core/src/routes/sso-connector/index.ts b/packages/core/src/routes/sso-connector/index.ts index 668a3e6aac27..c8a0258bdc1e 100644 --- a/packages/core/src/routes/sso-connector/index.ts +++ b/packages/core/src/routes/sso-connector/index.ts @@ -1,4 +1,5 @@ import { + ProductEvent, SsoConnectors, SsoProviderType, ssoConnectorProvidersResponseGuard, @@ -19,6 +20,7 @@ import { isSupportedSsoConnector, isSupportedSsoProvider } from '#src/sso/utils. import { tableToPathname } from '#src/utils/SchemaRouter.js'; import assertThat from '#src/utils/assert-that.js'; +import { captureEvent } from '../../utils/posthog.js'; import { type ManagementApiRouter, type RouterInitArgs } from '../types.js'; import ssoConnectorIdpInitiatedAuthConfigRoutes from './idp-initiated-auth-config.js'; @@ -152,6 +154,7 @@ export default function singleSignOnConnectorsRoutes { const { id } = ctx.guard.params; + const { providerName } = await getSsoConnectorById(id); await ssoConnectors.deleteById(id); + ctx.status = 204; + captureEvent(tenantId, ProductEvent.SsoConnectorDeleted, { providerName }); return next(); } ); diff --git a/packages/core/src/tenants/Libraries.ts b/packages/core/src/tenants/Libraries.ts index 6297aa67adeb..d59620708d49 100644 --- a/packages/core/src/tenants/Libraries.ts +++ b/packages/core/src/tenants/Libraries.ts @@ -25,7 +25,7 @@ import { createVerificationStatusLibrary } from '#src/libraries/verification-sta import type Queries from './Queries.js'; export default class Libraries { - users = createUserLibrary(this.queries); + users = createUserLibrary(this.tenantId, this.queries); phrases = createPhraseLibrary(this.queries); hooks = createHookLibrary(this.queries); scopes = createScopeLibrary(this.queries); diff --git a/packages/core/src/utils/SchemaRouter.ts b/packages/core/src/utils/SchemaRouter.ts index 698613600419..3d7f1683eecc 100644 --- a/packages/core/src/utils/SchemaRouter.ts +++ b/packages/core/src/utils/SchemaRouter.ts @@ -89,6 +89,11 @@ type SchemaRouterConfig = { /** Disable `DELETE /:id` route. */ deleteById: boolean; }; + /** Lifecycle hooks for certain actions. */ + hooks?: { + /** Triggered after an entity is deleted. */ + afterDelete?: () => void; + }; /** Middlewares that are used before creating API routes */ middlewares?: MiddlewareConfig[]; /** A custom error handler for the router before throwing the error. */ @@ -252,7 +257,7 @@ export default class SchemaRouter< response: relationSchema.guard.array(), status: [200, 404], }), - this.#ensembleQualifiedMiddlewares('get', true), + this.#assembleQualifiedMiddlewares('get', true), async (ctx, next) => { const { id } = ctx.guard.params; @@ -281,7 +286,7 @@ export default class SchemaRouter< body: z.object({ [columns.relationSchemaIds]: z.string().min(1).array().nonempty() }), status: [201, 422], }), - this.#ensembleQualifiedMiddlewares('post', true), + this.#assembleQualifiedMiddlewares('post', true), async (ctx, next) => { const { params: { id }, @@ -307,7 +312,7 @@ export default class SchemaRouter< body: z.object({ [columns.relationSchemaIds]: z.string().min(1).array() }), status: [204, 422], }), - this.#ensembleQualifiedMiddlewares('put', true), + this.#assembleQualifiedMiddlewares('put', true), async (ctx, next) => { const { params: { id }, @@ -329,7 +334,7 @@ export default class SchemaRouter< .extend({ [relationSchemaId]: z.string().min(1) }), status: [204, 422], }), - this.#ensembleQualifiedMiddlewares('delete', true), + this.#assembleQualifiedMiddlewares('delete', true), async (ctx, next) => { const { params: { id, [relationSchemaId]: relationId }, @@ -363,7 +368,7 @@ export default class SchemaRouter< response: (entityGuard ?? schema.guard).array(), status: [200], }), - this.#ensembleQualifiedMiddlewares('get'), + this.#assembleQualifiedMiddlewares('get'), async (ctx, next) => { const search = parseSearchOptions(searchFields, ctx.guard.query); const { limit, offset } = ctx.pagination; @@ -385,7 +390,7 @@ export default class SchemaRouter< response: entityGuard ?? schema.guard, status: [201], // TODO: 409/422 for conflict? }), - this.#ensembleQualifiedMiddlewares('post'), + this.#assembleQualifiedMiddlewares('post'), async (ctx, next) => { // eslint-disable-next-line no-restricted-syntax -- `.omit()` doesn't play well with generics ctx.body = await queries.insert({ @@ -406,7 +411,7 @@ export default class SchemaRouter< response: entityGuard ?? schema.guard, status: [200, 404], }), - this.#ensembleQualifiedMiddlewares('get'), + this.#assembleQualifiedMiddlewares('get'), async (ctx, next) => { ctx.body = await queries.findById(ctx.guard.params.id); return next(); @@ -423,7 +428,7 @@ export default class SchemaRouter< response: entityGuard ?? schema.guard, status: [200, 404], // TODO: 409/422 for conflict? }), - this.#ensembleQualifiedMiddlewares('patch'), + this.#assembleQualifiedMiddlewares('patch'), async (ctx, next) => { ctx.body = await queries.updateById(ctx.guard.params.id, ctx.guard.body); return next(); @@ -438,9 +443,10 @@ export default class SchemaRouter< params: z.object({ id: z.string().min(1) }), status: [204, 404], }), - this.#ensembleQualifiedMiddlewares('delete'), + this.#assembleQualifiedMiddlewares('delete'), async (ctx, next) => { await queries.deleteById(ctx.guard.params.id); + this.config.hooks?.afterDelete?.(); ctx.status = 204; return next(); } @@ -448,7 +454,7 @@ export default class SchemaRouter< } } - #ensembleQualifiedMiddlewares( + #assembleQualifiedMiddlewares( method: RouteMethod, isForRelationRoute = false ): Middleware { diff --git a/packages/core/src/utils/posthog.ts b/packages/core/src/utils/posthog.ts new file mode 100644 index 000000000000..555d264152f1 --- /dev/null +++ b/packages/core/src/utils/posthog.ts @@ -0,0 +1,51 @@ +import { EventGroup, tenantEventDistinctId, type ProductEvent } from '@logto/schemas'; +import { PostHog } from 'posthog-node'; + +import { EnvSet } from '../env-set/index.js'; + +/** + * PostHog client instance for server-side event tracking. + * If public key is not set, this will be `undefined`. + * + * @see {@link @logto/shared#GlobalValues} for global environment variable details. + * + * @remarks Don't export this instance directly if possible to avoid unnecessary actions. + */ +const postHog = EnvSet.values.posthogPublicKey + ? new PostHog(EnvSet.values.posthogPublicKey, { + host: EnvSet.values.posthogPublicHost, + }) + : undefined; + +export const shutdownPostHog = async () => postHog?.shutdown(); + +/** + * Capture developer-related events in the admin tenant. + */ +export const captureDeveloperEvent = ( + userId: string, + event: ProductEvent.DeveloperCreated | ProductEvent.DeveloperDeleted, + properties?: Record +) => + postHog?.capture({ + distinctId: userId, + event, + properties, + }); + +/** + * Capture tenant-specific events. These events will not be associated with a specific user. + */ +export const captureEvent = ( + tenantId: string, + event: ProductEvent, + properties?: Record +) => + postHog?.capture({ + distinctId: tenantEventDistinctId, + event, + groups: { + [EventGroup.Tenant]: tenantId, + }, + properties, + }); diff --git a/packages/schemas/src/consts/index.ts b/packages/schemas/src/consts/index.ts index c888419b8da8..efe67204901a 100644 --- a/packages/schemas/src/consts/index.ts +++ b/packages/schemas/src/consts/index.ts @@ -6,3 +6,4 @@ export * from './tenant.js'; export * from './subscriptions.js'; export * from './experience.js'; export * from './sentinel.js'; +export * from './product-event.js'; diff --git a/packages/schemas/src/consts/product-event.ts b/packages/schemas/src/consts/product-event.ts new file mode 100644 index 000000000000..62af18082f72 --- /dev/null +++ b/packages/schemas/src/consts/product-event.ts @@ -0,0 +1,64 @@ +/** + * The product events that Logto Cloud uses for analytics and auditing. + * + * - All events should be in past tense, with the format of ` `. + * - Unless otherwise specified, all events should contain tenant ID as the + * `tenant` group distinct ID. + * + * @remarks + * Events that are tracked in the cloud service will be marked with `@cloud`. + */ +export enum ProductEvent { + /** @cloud */ + TenantCreated = 'tenant created', + /** @cloud */ + TenantDeleted = 'tenant deleted', + /** @cloud */ + CollaboratorInvited = 'collaborator invited', + /** @cloud */ + ProPlanSubscribed = 'pro plan subscribed', + /** @cloud */ + ProPlanCanceled = 'pro plan canceled', + /** @cloud */ + FreePlanSubscribed = 'free plan subscribed', + /** + * A user has been created in the admin tenant. Interactive and non-interactive creations are + * both included. + */ + DeveloperCreated = 'developer created', + /** A user has been deleted in the admin tenant. */ + DeveloperDeleted = 'developer deleted', + AppCreated = 'app created', + AppDeleted = 'app deleted', + RoleCreated = 'role created', + RoleDeleted = 'role deleted', + ApiResourceCreated = 'api resource created', + ApiResourceDeleted = 'api resource deleted', + OrganizationRoleCreated = 'organization role created', + OrganizationRoleDeleted = 'organization role deleted', + SsoConnectorCreated = 'sso connector created', + SsoConnectorDeleted = 'sso connector deleted', + PasswordlessConnectorUpdated = 'passwordless connector updated', + SocialConnectorCreated = 'connector created', + SocialConnectorDeleted = 'connector deleted', + WebhookCreated = 'webhook created', + WebhookDeleted = 'webhook deleted', + CustomJwtDeployed = 'custom jwt deployed', + MfaEnabled = 'mfa enabled', + MfaDisabled = 'mfa disabled', + CustomDomainCreated = 'custom domain created', + CustomDomainDeleted = 'custom domain deleted', +} + +/** The PostHog groups for product events. */ +export enum EventGroup { + Tenant = 'tenant', +} + +/** + * The static distinct ID for tenant-level events. This is used when the event is not + * associated with a specific user. + * + * @see {@link https://posthog.com/docs/product-analytics/group-analytics#advanced-server-side-only-capturing-group-events-without-a-user} + */ +export const tenantEventDistinctId = 'TENANT_EVENT'; diff --git a/packages/shared/src/node/env/GlobalValues.ts b/packages/shared/src/node/env/GlobalValues.ts index 1cdbf0fa1fb8..94788418050a 100644 --- a/packages/shared/src/node/env/GlobalValues.ts +++ b/packages/shared/src/node/env/GlobalValues.ts @@ -116,9 +116,14 @@ export default class GlobalValues { public readonly databaseConnectionTimeout = Number(getEnv('DATABASE_CONNECTION_TIMEOUT', '5000')); - /** Case insensitive username */ + /** Global switch for enabling/disabling case-sensitive usernames. */ public readonly isCaseSensitiveUsername = yes(getEnv('CASE_SENSITIVE_USERNAME', 'true')); + /** The write-only key for PostHog integration. */ + public readonly posthogPublicKey = process.env.POSTHOG_PUBLIC_KEY; + /** The PostHog host URL for SDK to send events to. */ + public readonly posthogPublicHost = process.env.POSTHOG_PUBLIC_HOST; + /** * The Redis endpoint (optional). If it's set, the central cache mechanism will be automatically enabled. * diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dc88791d68a..bc3490835c1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3422,7 +3422,7 @@ importers: version: 1.11.13 debug: specifier: ^4.3.4 - version: 4.4.1(supports-color@5.5.0) + version: 4.4.1(supports-color@10.0.0) deep-object-diff: specifier: ^1.1.9 version: 1.1.9 @@ -3795,6 +3795,9 @@ importers: pluralize: specifier: ^8.0.0 version: 8.0.0 + posthog-node: + specifier: ^5.9.2 + version: 5.9.2 qrcode: specifier: ^1.5.3 version: 1.5.3 @@ -4210,7 +4213,7 @@ importers: version: 4.2.3 core-js: specifier: ^3.34.0 - version: 3.34.0 + version: 3.45.1 date-fns: specifier: ^2.29.3 version: 2.29.3 @@ -6277,6 +6280,9 @@ packages: '@posthog/core@1.2.1': resolution: {integrity: sha512-zNw96BipqM5/Tf161Q8/K5zpwGY3ezfb2wz+Yc3fIT5OQHW8eEzkQldPgtFKMUkqImc73ukEa2IdUpS6vEGH7w==} + '@posthog/core@1.2.2': + resolution: {integrity: sha512-f16Ozx6LIigRG+HsJdt+7kgSxZTHeX5f1JlCGKI1lXcvlZgfsCR338FuMI2QRYXGl+jg/vYFzGOTQBxl90lnBg==} + '@puppeteer/browsers@2.10.0': resolution: {integrity: sha512-HdHF4rny4JCvIcm7V1dpvpctIGqM3/Me255CB44vW7hDG1zYMmcBMjpNqZEDxdCfXGLkx5kP0+Jz5DUS+ukqtA==} engines: {node: '>=18'} @@ -9132,9 +9138,6 @@ packages: core-js-compat@3.37.0: resolution: {integrity: sha512-vYq4L+T8aS5UuFg4UwDhc7YNRWVeVZwltad9C/jV3R2LgVOpS9BDr7l/WL6BN0dbV3k1XejPTHqqEzJgsa0frA==} - core-js@3.34.0: - resolution: {integrity: sha512-aDdvlDder8QmY91H88GzNi9EtQi2TjvQhpCX6B1v/dAZHU1AuLgHvRh54RiOerpEhEW46Tkf+vgAViB/CWC0ag==} - core-js@3.45.1: resolution: {integrity: sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg==} @@ -13146,6 +13149,10 @@ packages: rrweb-snapshot: optional: true + posthog-node@5.9.2: + resolution: {integrity: sha512-oU7FbFcH5cn40nhP04cBeT67zE76EiGWjKKzDvm6IOm5P83sqM0Ij0wMJQSHp+QI6ZN7MLzb+4xfMPUEZ4q6CA==} + engines: {node: '>=20'} + postmark@4.0.5: resolution: {integrity: sha512-nerZdd3TwOH4CgGboZnlUM/q7oZk0EqpZgJL+Y3Nup8kHeaukxouQ6JcFF3EJEijc4QbuNv1TefGhboAKtf/SQ==} @@ -15929,7 +15936,7 @@ snapshots: '@babel/traverse': 7.24.1 '@babel/types': 7.27.0 convert-source-map: 2.0.0 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -15949,7 +15956,7 @@ snapshots: '@babel/traverse': 7.24.8 '@babel/types': 7.24.9 convert-source-map: 2.0.0 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -16241,7 +16248,7 @@ snapshots: '@babel/helper-split-export-declaration': 7.22.6 '@babel/parser': 7.27.0 '@babel/types': 7.27.0 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -16256,7 +16263,7 @@ snapshots: '@babel/helper-split-export-declaration': 7.24.7 '@babel/parser': 7.27.0 '@babel/types': 7.27.0 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -16673,7 +16680,7 @@ snapshots: '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.12.6 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) espree: 9.6.1 globals: 13.20.0 ignore: 5.3.1 @@ -16750,7 +16757,7 @@ snapshots: '@humanwhocodes/config-array@0.11.14': dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -17412,9 +17419,11 @@ snapshots: '@posthog/core@1.2.1': {} + '@posthog/core@1.2.2': {} + '@puppeteer/browsers@2.10.0': dependencies: - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 @@ -17427,7 +17436,7 @@ snapshots: '@puppeteer/browsers@2.6.1': dependencies: - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 @@ -19334,7 +19343,7 @@ snapshots: '@typescript-eslint/type-utils': 7.7.0(eslint@8.57.0)(typescript@5.5.3) '@typescript-eslint/utils': 7.7.0(eslint@8.57.0)(typescript@5.5.3) '@typescript-eslint/visitor-keys': 7.7.0 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) eslint: 8.57.0 graphemer: 1.4.0 ignore: 5.3.1 @@ -19352,7 +19361,7 @@ snapshots: '@typescript-eslint/types': 7.7.0 '@typescript-eslint/typescript-estree': 7.7.0(typescript@5.5.3) '@typescript-eslint/visitor-keys': 7.7.0 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) eslint: 8.57.0 optionalDependencies: typescript: 5.5.3 @@ -19368,7 +19377,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 7.7.0(typescript@5.5.3) '@typescript-eslint/utils': 7.7.0(eslint@8.57.0)(typescript@5.5.3) - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) eslint: 8.57.0 ts-api-utils: 1.3.0(typescript@5.5.3) optionalDependencies: @@ -19382,7 +19391,7 @@ snapshots: dependencies: '@typescript-eslint/types': 7.7.0 '@typescript-eslint/visitor-keys': 7.7.0 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.5 @@ -19429,7 +19438,7 @@ snapshots: dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-lib-source-maps: 5.0.6 @@ -19500,7 +19509,7 @@ snapshots: '@vonage/auth@1.12.0': dependencies: '@vonage/jwt': 1.11.0 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) transitivePeerDependencies: - supports-color @@ -19516,7 +19525,7 @@ snapshots: '@vonage/jwt@1.11.0': dependencies: - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) jsonwebtoken: 9.0.2 uuid: 9.0.1 transitivePeerDependencies: @@ -19526,7 +19535,7 @@ snapshots: dependencies: '@vonage/server-client': 1.16.0 '@vonage/vetch': 1.8.0 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) transitivePeerDependencies: - encoding - supports-color @@ -19571,7 +19580,7 @@ snapshots: dependencies: '@vonage/auth': 1.12.0 '@vonage/vetch': 1.8.0 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) lodash.camelcase: 4.3.0 lodash.isobject: 3.0.2 lodash.kebabcase: 4.1.1 @@ -19658,7 +19667,7 @@ snapshots: '@types/debug': 4.1.12 '@vonage/server-client': 1.16.0 '@vonage/vetch': 1.8.0 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) ts-xor: 1.3.0 transitivePeerDependencies: - encoding @@ -19967,7 +19976,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) transitivePeerDependencies: - supports-color @@ -20778,8 +20787,6 @@ snapshots: dependencies: browserslist: 4.23.2 - core-js@3.34.0: {} - core-js@3.45.1: {} cose-base@1.0.3: @@ -21639,7 +21646,7 @@ snapshots: eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0))(eslint@8.57.0): dependencies: - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) enhanced-resolve: 5.16.0 eslint: 8.57.0 eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) @@ -21791,7 +21798,7 @@ snapshots: eslint-plugin-sql@2.1.0(eslint@8.57.0): dependencies: astring: 1.8.3 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) eslint: 8.57.0 lodash: 4.17.21 pg-formatter: 1.3.0 @@ -21852,7 +21859,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -22012,7 +22019,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -22114,7 +22121,7 @@ snapshots: dependencies: chalk: 4.1.2 commander: 5.1.0 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) transitivePeerDependencies: - supports-color @@ -22161,7 +22168,7 @@ snapshots: follow-redirects@1.15.9(debug@4.4.1): optionalDependencies: - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) for-each@0.3.3: dependencies: @@ -22259,7 +22266,7 @@ snapshots: gaxios@6.1.1: dependencies: extend: 3.0.2 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@10.0.0) is-stream: 2.0.1 node-fetch: 2.7.0 transitivePeerDependencies: @@ -22350,7 +22357,7 @@ snapshots: dependencies: basic-ftp: 5.0.3 data-uri-to-buffer: 5.0.1 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) fs-extra: 8.1.0 transitivePeerDependencies: - supports-color @@ -22729,21 +22736,21 @@ snapshots: dependencies: '@tootallnate/once': 2.0.0 agent-base: 6.0.2 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) transitivePeerDependencies: - supports-color http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.3 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) transitivePeerDependencies: - supports-color http-proxy-middleware@3.0.5: dependencies: '@types/http-proxy': 1.17.15 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) http-proxy: 1.18.1(debug@4.4.1) is-glob: 4.0.3 is-plain-object: 5.0.0 @@ -22767,14 +22774,7 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.1(supports-color@5.5.0) - transitivePeerDependencies: - - supports-color - - https-proxy-agent@7.0.6: - dependencies: - agent-base: 7.1.3 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) transitivePeerDependencies: - supports-color @@ -23203,7 +23203,7 @@ snapshots: istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -23212,7 +23212,7 @@ snapshots: istanbul-lib-source-maps@5.0.6: dependencies: '@jridgewell/trace-mapping': 0.3.25 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color @@ -23678,7 +23678,7 @@ snapshots: form-data: 4.0.4 html-encoding-sniffer: 4.0.0 http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@10.0.0) is-potential-custom-element-name: 1.0.1 nwsapi: 2.2.20 parse5: 7.2.1 @@ -23858,7 +23858,7 @@ snapshots: koa-mount@4.0.0: dependencies: - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) koa-compose: 4.1.0 transitivePeerDependencies: - supports-color @@ -23874,7 +23874,7 @@ snapshots: koa-router@12.0.1: dependencies: - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) http-errors: 2.0.0 koa-compose: 4.1.0 methods: 1.1.2 @@ -23884,7 +23884,7 @@ snapshots: koa-send@5.0.1: dependencies: - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) http-errors: 1.8.1 resolve-path: 1.4.0 transitivePeerDependencies: @@ -23904,7 +23904,7 @@ snapshots: content-disposition: 0.5.4 content-type: 1.0.5 cookies: 0.9.1 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) delegates: 1.0.0 depd: 2.0.0 destroy: 1.2.0 @@ -24919,7 +24919,7 @@ snapshots: micromark@3.2.0: dependencies: '@types/debug': 4.1.12 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) decode-named-character-reference: 1.2.0 micromark-core-commonmark: 1.1.0 micromark-factory-space: 1.1.0 @@ -24941,7 +24941,7 @@ snapshots: micromark@4.0.0: dependencies: '@types/debug': 4.1.12 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) decode-named-character-reference: 1.0.1 devlop: 1.1.0 micromark-core-commonmark: 2.0.0 @@ -24963,7 +24963,7 @@ snapshots: micromark@4.0.2: dependencies: '@types/debug': 4.1.12 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) decode-named-character-reference: 1.2.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -25255,7 +25255,7 @@ snapshots: dependencies: '@koa/cors': 5.0.0 '@koa/router': 13.1.0 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) eta: 3.5.0 got: 13.0.0 jose: 5.9.6 @@ -25456,10 +25456,10 @@ snapshots: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.3 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) get-uri: 6.0.1 http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@10.0.0) pac-resolver: 7.0.1 socks-proxy-agent: 8.0.5 transitivePeerDependencies: @@ -25796,6 +25796,10 @@ snapshots: preact: 10.27.2 web-vitals: 4.2.4 + posthog-node@5.9.2: + dependencies: + '@posthog/core': 1.2.2 + postmark@4.0.5: dependencies: axios: 1.12.2 @@ -25871,9 +25875,9 @@ snapshots: proxy-agent@6.5.0: dependencies: agent-base: 7.1.3 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@10.0.0) lru-cache: 7.18.3 pac-proxy-agent: 7.1.0 proxy-from-env: 1.1.0 @@ -25900,7 +25904,7 @@ snapshots: dependencies: '@puppeteer/browsers': 2.6.1 chromium-bidi: 0.8.0(devtools-protocol@0.0.1367902) - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) devtools-protocol: 0.0.1367902 typed-query-selector: 2.12.0 ws: 8.18.1 @@ -25914,7 +25918,7 @@ snapshots: dependencies: '@puppeteer/browsers': 2.10.0 chromium-bidi: 3.0.0(devtools-protocol@0.0.1425554) - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) devtools-protocol: 0.0.1425554 typed-query-selector: 2.12.0 ws: 8.18.1 @@ -26435,7 +26439,7 @@ snapshots: require-in-the-middle@7.2.0: dependencies: - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) module-details-from-path: 1.0.3 resolve: 1.22.8 transitivePeerDependencies: @@ -26749,7 +26753,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.3 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) socks: 2.8.3 transitivePeerDependencies: - supports-color @@ -27026,7 +27030,7 @@ snapshots: cosmiconfig: 8.3.6(typescript@5.5.3) css-functions-list: 3.2.1 css-tree: 2.3.1 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) fast-glob: 3.3.2 fastest-levenshtein: 1.0.16 file-entry-cache: 7.0.2 @@ -27077,7 +27081,7 @@ snapshots: dependencies: component-emitter: 1.3.0 cookiejar: 2.1.4 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) fast-safe-stringify: 2.1.1 form-data: 4.0.4 formidable: 3.5.4 @@ -27355,7 +27359,7 @@ snapshots: cac: 6.7.14 chokidar: 4.0.3 consola: 3.4.2 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) esbuild: 0.25.3 fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 @@ -27668,7 +27672,7 @@ snapshots: vite-node@3.1.1(@types/node@22.14.1)(jiti@1.21.0)(lightningcss@1.25.1)(sass@1.77.8)(yaml@2.4.5): dependencies: cac: 6.7.14 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) es-module-lexer: 1.6.0 pathe: 2.0.3 vite: 6.3.6(@types/node@22.14.1)(jiti@1.21.0)(lightningcss@1.25.1)(sass@1.77.8)(yaml@2.4.5) @@ -27689,7 +27693,7 @@ snapshots: vite-plugin-compression@0.5.1(vite@6.3.6(@types/node@22.14.1)(jiti@1.21.0)(lightningcss@1.25.1)(sass@1.77.8)(yaml@2.4.5)): dependencies: chalk: 4.1.2 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) fs-extra: 10.1.0 vite: 6.3.6(@types/node@22.14.1)(jiti@1.21.0)(lightningcss@1.25.1)(sass@1.77.8)(yaml@2.4.5) transitivePeerDependencies: @@ -27732,7 +27736,7 @@ snapshots: '@vitest/spy': 3.1.1 '@vitest/utils': 3.1.1 chai: 5.2.0 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1(supports-color@10.0.0) expect-type: 1.2.1 magic-string: 0.30.17 pathe: 2.0.3 From a124d1fa23ce6d889797a780f20606aa2232a3d2 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Wed, 8 Oct 2025 00:32:24 -0700 Subject: [PATCH 2/2] refactor: fix tests --- .../classes/experience-interaction.test.ts | 2 +- .../actions/submit-interaction.mfa.test.ts | 6 ++--- .../actions/submit-interaction.test.ts | 12 ++++----- .../interaction/utils/single-sign-on.test.ts | 2 +- packages/schemas/src/consts/product-event.ts | 26 ++++++++++++++++--- 5 files changed, 33 insertions(+), 15 deletions(-) diff --git a/packages/core/src/routes/experience/classes/experience-interaction.test.ts b/packages/core/src/routes/experience/classes/experience-interaction.test.ts index 4f3aa3885488..35e216a2a6c6 100644 --- a/packages/core/src/routes/experience/classes/experience-interaction.test.ts +++ b/packages/core/src/routes/experience/classes/experience-interaction.test.ts @@ -113,7 +113,7 @@ describe('ExperienceInteraction class', () => { id: 'uid', primaryEmail: mockEmail, }, - ['user', 'default:admin'] + { isInteractive: true, roleNames: ['user', 'default:admin'] } ); expect(signInExperiences.updateDefaultSignInExperience).toHaveBeenCalledWith({ diff --git a/packages/core/src/routes/interaction/actions/submit-interaction.mfa.test.ts b/packages/core/src/routes/interaction/actions/submit-interaction.mfa.test.ts index 45bcf3d38a16..36582fa6926a 100644 --- a/packages/core/src/routes/interaction/actions/submit-interaction.mfa.test.ts +++ b/packages/core/src/routes/interaction/actions/submit-interaction.mfa.test.ts @@ -146,7 +146,7 @@ describe('submit action', () => { ], ...upsertProfile, }, - ['user'] + { isInteractive: true, roleNames: ['user'] } ); }); @@ -175,7 +175,7 @@ describe('submit action', () => { ], ...upsertProfile, }, - ['user'] + { isInteractive: true, roleNames: ['user'] } ); }); @@ -204,7 +204,7 @@ describe('submit action', () => { ], ...upsertProfile, }, - ['user'] + { isInteractive: true, roleNames: ['user'] } ); }); }); diff --git a/packages/core/src/routes/interaction/actions/submit-interaction.test.ts b/packages/core/src/routes/interaction/actions/submit-interaction.test.ts index 21db3db61b3d..8e491951a670 100644 --- a/packages/core/src/routes/interaction/actions/submit-interaction.test.ts +++ b/packages/core/src/routes/interaction/actions/submit-interaction.test.ts @@ -149,7 +149,7 @@ describe('submit action', () => { id: 'uid', ...upsertProfile, }, - ['user'] + { isInteractive: true, roleNames: ['user'] } ); expect(assignInteractionResults).toBeCalledWith(ctx, tenant.provider, { login: { accountId: 'uid' }, @@ -183,7 +183,7 @@ describe('submit action', () => { id: 'pending-account-id', ...upsertProfile, }, - ['user'] + { isInteractive: true, roleNames: ['user'] } ); expect(assignInteractionResults).toBeCalledWith(ctx, tenant.provider, { login: { accountId: 'pending-account-id' }, @@ -216,7 +216,7 @@ describe('submit action', () => { }, }, }, - ['user'] + { isInteractive: true, roleNames: ['user'] } ); }); @@ -247,7 +247,7 @@ describe('submit action', () => { primaryPhone: userInfo.phone, lastSignInAt: now, }, - ['user'] + { isInteractive: true, roleNames: ['user'] } ); }); @@ -279,7 +279,7 @@ describe('submit action', () => { avatar: userInfo.avatar, lastSignInAt: now, }, - ['user'] + { isInteractive: true, roleNames: ['user'] } ); }); @@ -314,7 +314,7 @@ describe('submit action', () => { id: 'uid', ...upsertProfile, }, - ['user', 'default:admin'] + { isInteractive: true, roleNames: ['user', 'default:admin'] } ); expect(assignInteractionResults).toBeCalledWith(adminConsoleCtx, tenant.provider, { login: { accountId: 'uid' }, diff --git a/packages/core/src/routes/interaction/utils/single-sign-on.test.ts b/packages/core/src/routes/interaction/utils/single-sign-on.test.ts index 4e3ab78108ba..00d9ea1fea64 100644 --- a/packages/core/src/routes/interaction/utils/single-sign-on.test.ts +++ b/packages/core/src/routes/interaction/utils/single-sign-on.test.ts @@ -344,7 +344,7 @@ describe('Single sign on util methods tests', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment lastSignInAt: expect.any(Number), }, - [] + { isInteractive: true } ); // Should create new user sso identity diff --git a/packages/schemas/src/consts/product-event.ts b/packages/schemas/src/consts/product-event.ts index 62af18082f72..6f6b6ebfb9ef 100644 --- a/packages/schemas/src/consts/product-event.ts +++ b/packages/schemas/src/consts/product-event.ts @@ -13,13 +13,31 @@ export enum ProductEvent { TenantCreated = 'tenant created', /** @cloud */ TenantDeleted = 'tenant deleted', - /** @cloud */ + /** + * One or more collaborators have been invited to the Logto Cloud tenant. + * + * @cloud + */ CollaboratorInvited = 'collaborator invited', - /** @cloud */ + /** + * The Logto Cloud tenant has subscribed to the Pro plan. It may be the first time subscribing, + * switching from the Free plan, or converting from a dev tenant, etc. + * + * @cloud + */ ProPlanSubscribed = 'pro plan subscribed', - /** @cloud */ + /** + * The Logto Cloud tenant has canceled the Pro plan. + * + * @cloud + */ ProPlanCanceled = 'pro plan canceled', - /** @cloud */ + /** + * The Logto Cloud tenant has subscribed to the Free plan. This may happen when a tenant + * newly created or downgrading from the Pro plan. + * + * @cloud + */ FreePlanSubscribed = 'free plan subscribed', /** * A user has been created in the admin tenant. Interactive and non-interactive creations are