diff --git a/.changeset/hip-fireants-talk.md b/.changeset/hip-fireants-talk.md new file mode 100644 index 000000000..978a7a0dd --- /dev/null +++ b/.changeset/hip-fireants-talk.md @@ -0,0 +1,79 @@ +--- +"@logto/console": minor +"@logto/core": minor +"@logto/schemas": minor +--- + +add new webhook events + +We introduce a new event type `DataHook` to unlock a series of events that can be triggered by data updates (mostly Management API): + +- User.Created +- User.Deleted +- User.Data.Updated +- User.SuspensionStatus.Updated +- Role.Created +- Role.Deleted +- Role.Data.Updated +- Role.Scopes.Updated +- Scope.Created +- Scope.Deleted +- Scope.Data.Updated +- Organization.Created +- Organization.Deleted +- Organization.Data.Updated +- Organization.Membership.Updated +- OrganizationRole.Created +- OrganizationRole.Deleted +- OrganizationRole.Data.Updated +- OrganizationRole.Scopes.Updated +- OrganizationScope.Created +- OrganizationScope.Deleted +- OrganizationScope.Data.Updated + +DataHook events are triggered when the data associated with the event is updated via management API request or user interaction actions. + +### Management API triggered events + +| API endpoint | Event | +| ---------------------------------------------------------- | ----------------------------------------------------------- | +| POST /users | User.Created | +| DELETE /users/:userId | User.Deleted | +| PATCH /users/:userId | User.Data.Updated | +| PATCH /users/:userId/custom-data | User.Data.Updated | +| PATCH /users/:userId/profile | User.Data.Updated | +| PATCH /users/:userId/password | User.Data.Updated | +| PATCH /users/:userId/is-suspended | User.SuspensionStatus.Updated | +| POST /roles | Role.Created, (Role.Scopes.Update) | +| DELETE /roles/:id | Role.Deleted | +| PATCH /roles/:id | Role.Data.Updated | +| POST /roles/:id/scopes | Role.Scopes.Updated | +| DELETE /roles/:id/scopes/:scopeId | Role.Scopes.Updated | +| POST /resources/:resourceId/scopes | Scope.Created | +| DELETE /resources/:resourceId/scopes/:scopeId | Scope.Deleted | +| PATCH /resources/:resourceId/scopes/:scopeId | Scope.Data.Updated | +| POST /organizations | Organization.Created | +| DELETE /organizations/:id | Organization.Deleted | +| PATCH /organizations/:id | Organization.Data.Updated | +| PUT /organizations/:id/users | Organization.Membership.Updated | +| POST /organizations/:id/users | Organization.Membership.Updated | +| DELETE /organizations/:id/users/:userId | Organization.Membership.Updated | +| POST /organization-roles | OrganizationRole.Created, (OrganizationRole.Scopes.Updated) | +| DELETE /organization-roles/:id | OrganizationRole.Deleted | +| PATCH /organization-roles/:id | OrganizationRole.Data.Updated | +| POST /organization-scopes | OrganizationScope.Created | +| DELETE /organization-scopes/:id | OrganizationScope.Deleted | +| PATCH /organization-scopes/:id | OrganizationScope.Data.Updated | +| PUT /organization-roles/:id/scopes | OrganizationRole.Scopes.Updated | +| POST /organization-roles/:id/scopes | OrganizationRole.Scopes.Updated | +| DELETE /organization-roles/:id/scopes/:organizationScopeId | OrganizationRole.Scopes.Updated | + +### User interaction triggered events + +| User interaction action | Event | +| ------------------------ | ----------------- | +| User email/phone linking | User.Data.Updated | +| User MFAs linking | User.Data.Updated | +| User social/SSO linking | User.Data.Updated | +| User password reset | User.Data.Updated | +| User registration | User.Created | diff --git a/packages/console/src/components/BasicWebhookForm/index.tsx b/packages/console/src/components/BasicWebhookForm/index.tsx index b3d11c503..f110c7b85 100644 --- a/packages/console/src/components/BasicWebhookForm/index.tsx +++ b/packages/console/src/components/BasicWebhookForm/index.tsx @@ -2,7 +2,6 @@ import { type Hook, type HookConfig, type HookEvent } from '@logto/schemas'; import { Controller, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { isDevFeaturesEnabled } from '@/consts/env'; import { dataHookEventsLabel, interactionHookEvents, @@ -18,15 +17,12 @@ import { uriValidator } from '@/utils/validator'; import * as styles from './index.module.scss'; const hookEventGroups: Array> = [ - // TODO: Remove dev feature guard - ...(isDevFeaturesEnabled - ? schemaGroupedDataHookEvents.map(([schema, events]) => ({ - title: dataHookEventsLabel[schema], - options: events.map((event) => ({ - value: event, - })), - })) - : []), + ...schemaGroupedDataHookEvents.map(([schema, events]) => ({ + title: dataHookEventsLabel[schema], + options: events.map((event) => ({ + value: event, + })), + })), { title: 'webhooks.schemas.interaction', options: interactionHookEvents.map((event) => ({ diff --git a/packages/console/src/consts/env.ts b/packages/console/src/consts/env.ts index 888eb2d73..b49e6b6f7 100644 --- a/packages/console/src/consts/env.ts +++ b/packages/console/src/consts/env.ts @@ -4,5 +4,6 @@ const isProduction = process.env.NODE_ENV === 'production'; export const isCloud = yes(process.env.IS_CLOUD); export const adminEndpoint = process.env.ADMIN_ENDPOINT; +// eslint-disable-next-line import/no-unused-modules export const isDevFeaturesEnabled = !isProduction || yes(process.env.DEV_FEATURES_ENABLED) || yes(process.env.INTEGRATION_TEST); diff --git a/packages/console/src/pages/WebhookDetails/WebhookLogs/index.tsx b/packages/console/src/pages/WebhookDetails/WebhookLogs/index.tsx index 219a36d1c..d89f41a84 100644 --- a/packages/console/src/pages/WebhookDetails/WebhookLogs/index.tsx +++ b/packages/console/src/pages/WebhookDetails/WebhookLogs/index.tsx @@ -8,8 +8,6 @@ import { z } from 'zod'; import EventSelector from '@/components/AuditLogTable/components/EventSelector'; import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder'; import { defaultPageSize } from '@/consts'; -import { isDevFeaturesEnabled } from '@/consts/env'; -import { interactionHookEvents } from '@/consts/webhooks'; import Table from '@/ds-components/Table'; import Tag from '@/ds-components/Tag'; import { type RequestError } from '@/hooks/use-api'; @@ -22,10 +20,7 @@ import { buildHookEventLogKey, getHookEventKey } from '../utils'; import * as styles from './index.module.scss'; -// TODO: Remove dev feature guard -const webhookEvents = isDevFeaturesEnabled ? hookEvents : interactionHookEvents; - -const hookLogEventOptions = webhookEvents.map((event) => ({ +const hookLogEventOptions = hookEvents.map((event) => ({ title: event, value: buildHookEventLogKey(event), })); diff --git a/packages/core/src/middleware/koa-management-api-hooks.ts b/packages/core/src/middleware/koa-management-api-hooks.ts index c86e47e7d..16c157d47 100644 --- a/packages/core/src/middleware/koa-management-api-hooks.ts +++ b/packages/core/src/middleware/koa-management-api-hooks.ts @@ -2,7 +2,6 @@ import { trySafe } from '@silverhand/essentials'; import { type MiddlewareType } from 'koa'; import { type IRouterParamContext } from 'koa-router'; -import { EnvSet } from '#src/env-set/index.js'; import { DataHookContextManager } from '#src/libraries/hook/context-manager.js'; import type Libraries from '#src/tenants/Libraries.js'; import { getConsoleLogFromContext } from '#src/utils/console.js'; @@ -22,12 +21,6 @@ export const koaManagementApiHooks = , ResponseT> => { return async (ctx, next) => { - // TODO: Remove dev feature guard - const { isDevFeaturesEnabled } = EnvSet.values; - if (!isDevFeaturesEnabled) { - return next(); - } - const { header: { 'user-agent': userAgent }, ip, diff --git a/packages/core/src/routes/hook.ts b/packages/core/src/routes/hook.ts index b2989e8f1..34755c6c7 100644 --- a/packages/core/src/routes/hook.ts +++ b/packages/core/src/routes/hook.ts @@ -6,7 +6,6 @@ import { hookEventGuard, hookEventsGuard, hookResponseGuard, - interactionHookEventGuard, type Hook, type HookResponse, } from '@logto/schemas'; @@ -15,7 +14,6 @@ import { conditional, deduplicate, yes } from '@silverhand/essentials'; import { subDays } from 'date-fns'; import { z } from 'zod'; -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'; @@ -25,12 +23,7 @@ import assertThat from '#src/utils/assert-that.js'; import type { ManagementApiRouter, RouterInitArgs } from './types.js'; -const { isDevFeaturesEnabled } = EnvSet.values; -// TODO: remove dev features guard -const webhookEventsGuard = isDevFeaturesEnabled - ? hookEventsGuard - : interactionHookEventGuard.array(); -const nonemptyUniqueHookEventsGuard = webhookEventsGuard +const nonemptyUniqueHookEventsGuard = hookEventsGuard .nonempty() .transform((events) => deduplicate(events)); @@ -167,8 +160,7 @@ export default function hookRoutes( koaQuotaGuard({ key: 'hooksLimit', quota }), koaGuard({ body: Hooks.createGuard.omit({ id: true, signingKey: true }).extend({ - // TODO: remove dev features guard - event: (isDevFeaturesEnabled ? hookEventGuard : interactionHookEventGuard).optional(), + event: hookEventGuard.optional(), events: nonemptyUniqueHookEventsGuard.optional(), }), response: Hooks.guard, diff --git a/packages/core/src/routes/interaction/middleware/koa-interaction-hooks.ts b/packages/core/src/routes/interaction/middleware/koa-interaction-hooks.ts index 7e51f0041..3ce01f274 100644 --- a/packages/core/src/routes/interaction/middleware/koa-interaction-hooks.ts +++ b/packages/core/src/routes/interaction/middleware/koa-interaction-hooks.ts @@ -1,9 +1,8 @@ import { userInfoSelectFields, type DataHookEvent, type User } from '@logto/schemas'; -import { conditional, conditionalString, noop, pick, trySafe } from '@silverhand/essentials'; +import { conditional, conditionalString, pick, trySafe } from '@silverhand/essentials'; import type { MiddlewareType } from 'koa'; import type { IRouterParamContext } from 'koa-router'; -import { EnvSet } from '#src/env-set/index.js'; import { DataHookContextManager, InteractionHookContextManager, @@ -41,7 +40,6 @@ export default function koaInteractionHooks< hooks: { triggerInteractionHooks, triggerDataHooks }, }: Libraries): MiddlewareType, ResponseT> { return async (ctx, next) => { - const { isDevFeaturesEnabled } = EnvSet.values; const { event: interactionEvent } = getInteractionStorage(ctx.interactionDetails.result); const { @@ -71,7 +69,7 @@ export default function koaInteractionHooks< }); // Assign user and event data to the data hook context - const assignDataHookContext: AssignDataHookContext = ({ event, user, data: extraData }) => { + ctx.assignDataHookContext = ({ event, user, data: extraData }) => { dataHookContext.appendContext({ event, data: { @@ -82,9 +80,6 @@ export default function koaInteractionHooks< }); }; - // TODO: remove dev features check - ctx.assignDataHookContext = isDevFeaturesEnabled ? assignDataHookContext : noop; - await next(); if (interactionHookContext.interactionHookResult) { @@ -92,8 +87,7 @@ export default function koaInteractionHooks< void trySafe(triggerInteractionHooks(getConsoleLogFromContext(ctx), interactionHookContext)); } - // TODO: remove dev features check - if (isDevFeaturesEnabled && dataHookContext.contextArray.length > 0) { + if (dataHookContext.contextArray.length > 0) { // Hooks should not crash the app void trySafe(triggerDataHooks(getConsoleLogFromContext(ctx), dataHookContext)); } diff --git a/packages/core/src/routes/organization/roles.ts b/packages/core/src/routes/organization/roles.ts index df744ae6f..2f5c630b7 100644 --- a/packages/core/src/routes/organization/roles.ts +++ b/packages/core/src/routes/organization/roles.ts @@ -8,7 +8,6 @@ import { import { generateStandardId } from '@logto/shared'; 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'; @@ -112,17 +111,11 @@ export default function organizationRoleRoutes( ); } - const { isDevFeaturesEnabled } = EnvSet.values; - ctx.body = role; ctx.status = 201; // Trigger `OrganizationRole.Scope.Updated` event if organizationScopeIds or resourceScopeIds are provided. - // TODO: remove dev feature guard - if ( - isDevFeaturesEnabled && - (organizationScopeIds.length > 0 || resourceScopeIds.length > 0) - ) { + if (organizationScopeIds.length > 0 || resourceScopeIds.length > 0) { ctx.appendDataHookContext({ event: 'OrganizationRole.Scopes.Updated', ...buildManagementApiContext(ctx), diff --git a/packages/core/src/routes/role.ts b/packages/core/src/routes/role.ts index 3744c77d5..a26db72b1 100644 --- a/packages/core/src/routes/role.ts +++ b/packages/core/src/routes/role.ts @@ -4,7 +4,6 @@ import { generateStandardId } from '@logto/shared'; import { pickState, trySafe, tryThat } from '@silverhand/essentials'; import { number, object, string, z } from 'zod'; -import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import { buildManagementApiContext } from '#src/libraries/hook/utils.js'; import koaGuard from '#src/middleware/koa-guard.js'; @@ -176,23 +175,18 @@ export default function roleRoutes( scopeIds.map((scopeId) => ({ id: generateStandardId(), roleId: role.id, scopeId })) ); - const { isDevFeaturesEnabled } = EnvSet.values; + // Trigger the `Role.Scopes.Updated` event if scopeIds are provided. Should not break the request + await trySafe(async () => { + // Align the response type with POST /roles/:id/scopes + const newRolesScopes = await findScopesByIds(scopeIds); - // TODO: Remove dev feature guard - if (isDevFeaturesEnabled) { - // Trigger the `Role.Scopes.Updated` event if scopeIds are provided. Should not break the request - await trySafe(async () => { - // Align the response type with POST /roles/:id/scopes - const newRolesScopes = await findScopesByIds(scopeIds); - - ctx.appendDataHookContext({ - event: 'Role.Scopes.Updated', - ...buildManagementApiContext(ctx), - roleId: role.id, - data: newRolesScopes, - }); + ctx.appendDataHookContext({ + event: 'Role.Scopes.Updated', + ...buildManagementApiContext(ctx), + roleId: role.id, + data: newRolesScopes, }); - } + }); } ctx.body = role;