From e7a642028d0f962faacaa7ebc815830bf3bbff6b Mon Sep 17 00:00:00 2001 From: simeng-li Date: Mon, 13 May 2024 16:11:50 +0800 Subject: [PATCH] feat(schemas): define data hook events (#5828) * feat(schemas): define data hook events define data hook events * fix(schemas,core): fix the type error fix the type error * fix(core): fix unit test fix unit test * feat(test): add integration tests for DataHooks add integration tests for DataHooks * fix(test): fix ut of management api hook middleware fix ut of the management api hook middleware * refactor(test,core,schemas): refactor some DataHook definiations refactor some DataHook definitations * chore(test): remove upper scope describe wrap remove upper scope describe wrap * fix(test): fix tests fix tests * refactor(schemas): rename the info.update events rename the info.update events * refactor(schemas): rename rename * refactor(core,schemas): refactor DataHook code refactor DataHook code to address some code review comments * fix(test): fix ut fix ut * fix(schemas): update DataHookEventPayload type update DataHookEventPayload type * chore(schemas): update comments update comments --- .../koa-management-api-hooks.test.ts | 19 +- .../middleware/koa-management-api-hooks.ts | 19 +- packages/core/src/utils/test-utils.ts | 4 +- packages/integration-tests/package.json | 3 +- .../src/tests/api/hook/WebhookMockServer.ts | 64 ++++ .../tests/api/hook/hook.trigger.data.test.ts | 342 ++++++++++++++++++ ...st.ts => hook.trigger.interaction.test.ts} | 0 .../src/tests/api/hook/test-cases.ts | 195 ++++++++++ .../src/foundations/jsonb-types/hooks.ts | 77 +++- packages/schemas/src/types/hook.ts | 12 +- pnpm-lock.yaml | 3 + 11 files changed, 720 insertions(+), 18 deletions(-) create mode 100644 packages/integration-tests/src/tests/api/hook/WebhookMockServer.ts create mode 100644 packages/integration-tests/src/tests/api/hook/hook.trigger.data.test.ts rename packages/integration-tests/src/tests/api/hook/{hook.trigger.test.ts => hook.trigger.interaction.test.ts} (100%) create mode 100644 packages/integration-tests/src/tests/api/hook/test-cases.ts diff --git a/packages/core/src/middleware/koa-management-api-hooks.test.ts b/packages/core/src/middleware/koa-management-api-hooks.test.ts index eba7130c5..2703d88e0 100644 --- a/packages/core/src/middleware/koa-management-api-hooks.test.ts +++ b/packages/core/src/middleware/koa-management-api-hooks.test.ts @@ -65,15 +65,19 @@ describe('koaManagementApiHooks', () => { it.each(events)('should append hook context for %s', async (key, event) => { const [method, route] = key.split(' ') as [string, string]; + const ctxParams = createContextWithRouteParameters(); const ctx: ParameterizedContext = { - ...createContextWithRouteParameters(), + ...ctxParams, header: {}, appendDataHookContext: notToBeCalled, method, _matchedRoute: route, path: route, - body: { key }, + response: { + ...ctxParams.response, + body: { key }, + }, status: 200, }; @@ -85,7 +89,16 @@ describe('koaManagementApiHooks', () => { contextArray: [ { event, - data: { path: route, method, body: { key }, status: 200 }, + data: { + path: route, + method, + response: { + body: { key }, + }, + params: ctxParams.params, + matchedRoute: route, + status: 200, + }, }, ], }) diff --git a/packages/core/src/middleware/koa-management-api-hooks.ts b/packages/core/src/middleware/koa-management-api-hooks.ts index f941d8f40..c1a949afd 100644 --- a/packages/core/src/middleware/koa-management-api-hooks.ts +++ b/packages/core/src/middleware/koa-management-api-hooks.ts @@ -49,13 +49,24 @@ export const koaManagementApiHooks = 0) { diff --git a/packages/core/src/utils/test-utils.ts b/packages/core/src/utils/test-utils.ts index fa98c5e48..98ea31c5a 100644 --- a/packages/core/src/utils/test-utils.ts +++ b/packages/core/src/utils/test-utils.ts @@ -4,13 +4,13 @@ import type { PrimitiveValueExpression, TaggedTemplateLiteralInvocation, } from '@silverhand/slonik/dist/src/types.js'; -import type { MiddlewareType, Context, Middleware } from 'koa'; +import type { Context, Middleware, MiddlewareType } from 'koa'; import Koa from 'koa'; import type { IRouterParamContext } from 'koa-router'; import Router from 'koa-router'; import request from 'supertest'; -import type { ManagementApiRouter, AnonymousRouter } from '#src/routes/types.js'; +import type { AnonymousRouter, ManagementApiRouter } from '#src/routes/types.js'; import type TenantContext from '#src/tenants/TenantContext.js'; import type { Options } from '#src/test-utils/jest-koa-mocks/create-mock-context.js'; import createMockContext from '#src/test-utils/jest-koa-mocks/create-mock-context.js'; diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index a674bbb48..b336684a1 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -48,7 +48,8 @@ "prettier": "^3.0.0", "puppeteer": "^22.6.5", "text-encoder": "^0.0.4", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "zod": "^3.22.4" }, "engines": { "node": "^20.9.0" diff --git a/packages/integration-tests/src/tests/api/hook/WebhookMockServer.ts b/packages/integration-tests/src/tests/api/hook/WebhookMockServer.ts new file mode 100644 index 000000000..952b8f955 --- /dev/null +++ b/packages/integration-tests/src/tests/api/hook/WebhookMockServer.ts @@ -0,0 +1,64 @@ +import { createServer, type RequestListener, type Server } from 'node:http'; + +/** + * A mock server that listens for incoming requests and responds with the request body. + * + * @example + * const server = new WebhookMockServer(3000); + * await server.listen(); + */ +class WebhookMockServer { + public readonly endpoint = `http://localhost:${this.port}`; + private readonly server: Server; + + constructor( + /** The port number to listen on. */ + private readonly port: number, + /** A callback that is called with the request body when a request is received. */ + requestCallback?: (body: string) => void + ) { + const requestListener: RequestListener = (request, response) => { + const data: Uint8Array[] = []; + + request.on('data', (chunk: Uint8Array) => { + // eslint-disable-next-line @silverhand/fp/no-mutating-methods + data.push(chunk); + }); + + request.on('end', () => { + response.writeHead(200, { 'Content-Type': 'application/json' }); + + const payload: unknown = JSON.parse(Buffer.concat(data).toString()); + + const body = JSON.stringify({ + signature: request.headers['logto-signature-sha-256'], + payload, + }); + + requestCallback?.(body); + + response.end(body); + }); + }; + + this.server = createServer(requestListener); + } + + public async listen() { + return new Promise((resolve) => { + this.server.listen(this.port, () => { + resolve(true); + }); + }); + } + + public async close() { + return new Promise((resolve) => { + this.server.close(() => { + resolve(true); + }); + }); + } +} + +export default WebhookMockServer; diff --git a/packages/integration-tests/src/tests/api/hook/hook.trigger.data.test.ts b/packages/integration-tests/src/tests/api/hook/hook.trigger.data.test.ts new file mode 100644 index 000000000..379813290 --- /dev/null +++ b/packages/integration-tests/src/tests/api/hook/hook.trigger.data.test.ts @@ -0,0 +1,342 @@ +import { + RoleType, + hookEventGuard, + hookEvents, + jsonGuard, + managementApiHooksRegistration, + type Hook, + type Role, +} from '@logto/schemas'; +import { z } from 'zod'; + +import { authedAdminApi } from '#src/api/api.js'; +import { createResource } from '#src/api/resource.js'; +import { createScope } from '#src/api/scope.js'; +import { + OrganizationApiTest, + OrganizationRoleApiTest, + OrganizationScopeApiTest, +} from '#src/helpers/organization.js'; +import { UserApiTest, generateNewUser } from '#src/helpers/user.js'; +import { generateName, waitFor } from '#src/utils.js'; + +import WebhookMockServer from './WebhookMockServer.js'; +import { + organizationDataHookTestCases, + organizationRoleDataHookTestCases, + organizationScopeDataHookTestCases, + roleDataHookTestCases, + scopesDataHookTestCases, + userDataHookTestCases, +} from './test-cases.js'; + +const mockHookResponseGuard = z.object({ + signature: z.string(), + payload: z.object({ + event: hookEventGuard, + createdAt: z.string(), + hookId: z.string(), + body: jsonGuard.optional(), + method: z + .string() + .optional() + .transform((value) => value?.toUpperCase()), + matchedRoute: z.string().optional(), + }), +}); + +type MockHookResponse = z.infer; + +const hookName = 'management-api-hook'; +const webhooks = new Map(); +const webhookResults = new Map(); + +// Record the hook response to the webhookResults map. +// Compare the webhookResults map with the managementApiHooksRegistration to verify all hook is triggered. +const webhookResponseHandler = (response: string) => { + const result = mockHookResponseGuard.parse(JSON.parse(response)); + const { payload } = result; + + // Use matchedRoute as the key + if (payload.matchedRoute) { + webhookResults.set(`${payload.method} ${payload.matchedRoute}`, result); + } +}; + +/** + * Get the webhook result by the key. + * + * @remark Since the webhook request is async, we need to wait for a while + * to ensure the webhook response is received. + */ +const getWebhookResult = async (key: string) => { + await waitFor(100); + + return webhookResults.get(key); +}; + +const webhookServer = new WebhookMockServer(9999, webhookResponseHandler); + +beforeAll(async () => { + await webhookServer.listen(); + + const webhookInstance = await authedAdminApi + .post('hooks', { + json: { + name: hookName, + events: [...hookEvents], + config: { + url: webhookServer.endpoint, + headers: { foo: 'bar' }, + }, + }, + }) + .json(); + + webhooks.set(hookName, webhookInstance); +}); + +afterAll(async () => { + await Promise.all( + Array.from(webhooks.values()).map(async (hook) => authedAdminApi.delete(`hooks/${hook.id}`)) + ); + + await webhookServer.close(); +}); + +describe('user data hook events', () => { + // eslint-disable-next-line @silverhand/fp/no-let + let userId: string; + + beforeAll(async () => { + // Create a user to trigger the User.Created event. + const { user } = await generateNewUser({ username: true, password: true }); + // eslint-disable-next-line @silverhand/fp/no-mutation + userId = user.id; + const userCreateHook = await getWebhookResult('POST /users'); + expect(userCreateHook?.payload.event).toBe('User.Created'); + }); + + it.each(userDataHookTestCases)( + 'test case %#: %p', + async ({ route, event, method, endpoint, payload }) => { + await authedAdminApi[method](endpoint.replace('{userId}', userId), { json: payload }); + const hook = await getWebhookResult(route); + expect(hook?.payload.event).toBe(event); + } + ); +}); + +describe('role data hook events', () => { + /* eslint-disable @silverhand/fp/no-let */ + let roleId: string; + let scopeId: string; + let resourceId: string; + /* eslint-enable @silverhand/fp/no-let */ + + beforeAll(async () => { + // Create a role to trigger the Role.Created event. + const role = await authedAdminApi + .post('roles', { + json: { name: generateName(), description: 'data-hook-role', type: RoleType.User }, + }) + .json(); + + const roleCreateHook = await getWebhookResult('POST /roles'); + expect(roleCreateHook?.payload.event).toBe('Role.Created'); + + // Prepare the role and scope id for the Role.Scopes.Updated event. + /* eslint-disable @silverhand/fp/no-mutation */ + roleId = role.id; + const resource = await createResource(); + const scope = await createScope(resource.id); + + scopeId = scope.id; + resourceId = resource.id; + /* eslint-enable @silverhand/fp/no-mutation */ + }); + + afterAll(async () => { + await authedAdminApi.delete(`resources/${resourceId}`); + }); + + it.each(roleDataHookTestCases)( + 'test case %#: %p', + async ({ route, event, method, endpoint, payload }) => { + await authedAdminApi[method]( + endpoint.replace('{roleId}', roleId).replace('{scopeId}', scopeId), + // Replace all the scopeId placeholder in the payload + { json: JSON.parse(JSON.stringify(payload).replace('{scopeId}', scopeId)) } + ); + const hook = await getWebhookResult(route); + expect(hook?.payload.event).toBe(event); + } + ); +}); + +describe('scope data hook events', () => { + /* eslint-disable @silverhand/fp/no-let */ + let resourceId: string; + let scopeId: string; + /* eslint-enable @silverhand/fp/no-let */ + + beforeAll(async () => { + const resource = await createResource(); + const scope = await createScope(resource.id); + + /* eslint-disable @silverhand/fp/no-mutation */ + resourceId = resource.id; + scopeId = scope.id; + /* eslint-enable @silverhand/fp/no-mutation */ + + const scopesCreateHook = await getWebhookResult('POST /resources/:resourceId/scopes'); + expect(scopesCreateHook?.payload.event).toBe('Scope.Created'); + }); + + afterAll(async () => { + await authedAdminApi.delete(`resources/${resourceId}`); + }); + + it.each(scopesDataHookTestCases)( + 'test case %#: %p', + async ({ route, event, method, endpoint, payload }) => { + await authedAdminApi[method]( + endpoint.replace('{resourceId}', resourceId).replace('{scopeId}', scopeId), + { json: payload } + ); + const hook = await getWebhookResult(route); + expect(hook?.payload.event).toBe(event); + } + ); +}); + +describe('organization data hook events', () => { + /* eslint-disable @silverhand/fp/no-let */ + let organizationId: string; + let userId: string; + /* eslint-enable @silverhand/fp/no-let */ + + const organizationApi = new OrganizationApiTest(); + const userApi = new UserApiTest(); + + beforeAll(async () => { + const organization = await organizationApi.create({ + name: generateName(), + description: 'organization data hook test organization.', + }); + + const user = await userApi.create({ name: generateName() }); + + /* eslint-disable @silverhand/fp/no-mutation */ + organizationId = organization.id; + userId = user.id; + /* eslint-enable @silverhand/fp/no-mutation */ + + const organizationCreateHook = await getWebhookResult('POST /organizations'); + expect(organizationCreateHook?.payload.event).toBe('Organization.Created'); + }); + + afterAll(async () => { + await userApi.cleanUp(); + }); + + it.each(organizationDataHookTestCases)( + 'test case %#: %p', + async ({ route, event, method, endpoint, payload }) => { + await authedAdminApi[method]( + endpoint.replace('{organizationId}', organizationId).replace('{userId}', userId), + { json: JSON.parse(JSON.stringify(payload).replace('{userId}', userId)) } + ); + const hook = await getWebhookResult(route); + expect(hook?.payload.event).toBe(event); + } + ); +}); + +describe('organization scope data hook events', () => { + /* eslint-disable @silverhand/fp/no-let */ + let scopeId: string; + /* eslint-enable @silverhand/fp/no-let */ + + const organizationScopeApi = new OrganizationScopeApiTest(); + + beforeAll(async () => { + const scope = await organizationScopeApi.create({ + name: generateName(), + description: 'organization scope data hook test scope.', + }); + + /* eslint-disable @silverhand/fp/no-mutation */ + scopeId = scope.id; + /* eslint-enable @silverhand/fp/no-mutation */ + + const organizationScopeCreateHook = await getWebhookResult('POST /organization-scopes'); + expect(organizationScopeCreateHook?.payload.event).toBe('OrganizationScope.Created'); + }); + + it.each(organizationScopeDataHookTestCases)( + 'test case %#: %p', + async ({ route, event, method, endpoint, payload }) => { + await authedAdminApi[method](endpoint.replace('{organizationScopeId}', scopeId), { + json: payload, + }); + const hook = await getWebhookResult(route); + expect(hook?.payload.event).toBe(event); + } + ); +}); + +describe('organization role data hook events', () => { + /* eslint-disable @silverhand/fp/no-let */ + let roleId: string; + let scopeId: string; + /* eslint-enable @silverhand/fp/no-let */ + + const organizationScopeApi = new OrganizationScopeApiTest(); + const roleApi = new OrganizationRoleApiTest(); + + beforeAll(async () => { + const role = await roleApi.create({ + name: generateName(), + description: 'organization role data hook test role.', + }); + + const scope = await organizationScopeApi.create({ + name: generateName(), + description: 'organization role data hook test scope.', + }); + + /* eslint-disable @silverhand/fp/no-mutation */ + roleId = role.id; + scopeId = scope.id; + /* eslint-enable @silverhand/fp/no-mutation */ + + const organizationRoleCreateHook = await getWebhookResult('POST /organization-roles'); + expect(organizationRoleCreateHook?.payload.event).toBe('OrganizationRole.Created'); + }); + + afterAll(async () => { + await organizationScopeApi.cleanUp(); + }); + + it.each(organizationRoleDataHookTestCases)( + 'test case %#: %p', + async ({ route, event, method, endpoint, payload }) => { + await authedAdminApi[method]( + endpoint.replace('{organizationRoleId}', roleId).replace('{scopeId}', scopeId), + { json: JSON.parse(JSON.stringify(payload).replace('{scopeId}', scopeId)) } + ); + const hook = await getWebhookResult(route); + expect(hook?.payload.event).toBe(event); + } + ); +}); + +describe('data hook events coverage', () => { + const keys = Object.keys(managementApiHooksRegistration); + it.each(keys)('should have test case for %s', async (key) => { + const webhookResult = await getWebhookResult(key); + expect(webhookResult).toBeDefined(); + expect(webhookResult?.signature).toBeDefined(); + }); +}); diff --git a/packages/integration-tests/src/tests/api/hook/hook.trigger.test.ts b/packages/integration-tests/src/tests/api/hook/hook.trigger.interaction.test.ts similarity index 100% rename from packages/integration-tests/src/tests/api/hook/hook.trigger.test.ts rename to packages/integration-tests/src/tests/api/hook/hook.trigger.interaction.test.ts diff --git a/packages/integration-tests/src/tests/api/hook/test-cases.ts b/packages/integration-tests/src/tests/api/hook/test-cases.ts new file mode 100644 index 000000000..4acdc1377 --- /dev/null +++ b/packages/integration-tests/src/tests/api/hook/test-cases.ts @@ -0,0 +1,195 @@ +import { generateName } from '#src/utils.js'; + +type TestCase = { + route: string; + event: string; + method: 'patch' | 'post' | 'delete' | 'put'; + endpoint: string; + payload: Record; +}; + +export const userDataHookTestCases: TestCase[] = [ + { + route: 'PATCH /users/:userId', + event: 'User.Updated', + method: 'patch', + endpoint: `users/{userId}`, + payload: { name: 'new name' }, + }, + { + route: 'PATCH /users/:userId/custom-data', + event: 'User.Updated', + method: 'patch', + endpoint: `users/{userId}/custom-data`, + payload: { customData: { foo: 'bar' } }, + }, + { + route: 'PATCH /users/:userId/profile', + event: 'User.Updated', + method: 'patch', + endpoint: `users/{userId}/profile`, + payload: { profile: { nickname: 'darcy' } }, + }, + { + route: 'PATCH /users/:userId/password', + event: 'User.Updated', + method: 'patch', + endpoint: `users/{userId}/password`, + payload: { password: 'new-password' }, + }, + { + route: 'PATCH /users/:userId/is-suspended', + event: 'User.SuspensionStatus.Updated', + method: 'patch', + endpoint: `users/{userId}/is-suspended`, + payload: { isSuspended: true }, + }, + { + route: 'DELETE /users/:userId', + event: 'User.Deleted', + method: 'delete', + endpoint: `users/{userId}`, + payload: {}, + }, +]; + +export const roleDataHookTestCases: TestCase[] = [ + { + route: 'PATCH /roles/:id', + event: 'Role.Updated', + method: 'patch', + endpoint: `roles/{roleId}`, + payload: { name: 'new name' }, + }, + { + route: 'POST /roles/:id/scopes', + event: 'Role.Scopes.Updated', + method: 'post', + endpoint: `roles/{roleId}/scopes`, + payload: { scopeIds: ['{scopeId}'] }, + }, + { + route: 'DELETE /roles/:id/scopes/:scopeId', + event: 'Role.Scopes.Updated', + method: 'delete', + endpoint: `roles/{roleId}/scopes/{scopeId}`, + payload: {}, + }, + { + route: 'DELETE /roles/:id', + event: 'Role.Deleted', + method: 'delete', + endpoint: `roles/{roleId}`, + payload: {}, + }, +]; + +export const scopesDataHookTestCases: TestCase[] = [ + { + route: 'PATCH /resources/:resourceId/scopes/:scopeId', + event: 'Scope.Updated', + method: 'patch', + endpoint: `resources/{resourceId}/scopes/{scopeId}`, + payload: { name: generateName() }, + }, + { + route: 'DELETE /resources/:resourceId/scopes/:scopeId', + event: 'Scope.Deleted', + method: 'delete', + endpoint: `resources/{resourceId}/scopes/{scopeId}`, + payload: {}, + }, +]; + +export const organizationDataHookTestCases: TestCase[] = [ + { + route: 'PATCH /organizations/:id', + event: 'Organization.Updated', + method: 'patch', + endpoint: `organizations/{organizationId}`, + payload: { description: 'new org description' }, + }, + { + route: 'POST /organizations/:id/users', + event: 'Organization.Membership.Updated', + method: 'post', + endpoint: `organizations/{organizationId}/users`, + payload: { userIds: ['{userId}'] }, + }, + { + route: 'PUT /organizations/:id/users', + event: 'Organization.Membership.Updated', + method: 'put', + endpoint: `organizations/{organizationId}/users`, + payload: { userIds: ['{userId}'] }, + }, + { + route: 'DELETE /organizations/:id/users/:userId', + event: 'Organization.Membership.Updated', + method: 'delete', + endpoint: `organizations/{organizationId}/users/{userId}`, + payload: {}, + }, + { + route: 'DELETE /organizations/:id', + event: 'Organization.Deleted', + method: 'delete', + endpoint: `organizations/{organizationId}`, + payload: {}, + }, +]; + +export const organizationScopeDataHookTestCases: TestCase[] = [ + { + route: 'PATCH /organization-scopes/:id', + event: 'OrganizationScope.Updated', + method: 'patch', + endpoint: `organization-scopes/{organizationScopeId}`, + payload: { description: 'new org scope description' }, + }, + { + route: 'DELETE /organization-scopes/:id', + event: 'OrganizationScope.Deleted', + method: 'delete', + endpoint: `organization-scopes/{organizationScopeId}`, + payload: {}, + }, +]; + +export const organizationRoleDataHookTestCases: TestCase[] = [ + { + route: 'PATCH /organization-roles/:id', + event: 'OrganizationRole.Updated', + method: 'patch', + endpoint: `organization-roles/{organizationRoleId}`, + payload: { name: generateName() }, + }, + { + route: 'POST /organization-roles/:id/scopes', + event: 'OrganizationRole.Scopes.Updated', + method: 'post', + endpoint: `organization-roles/{organizationRoleId}/scopes`, + payload: { organizationScopeIds: ['{scopeId}'] }, + }, + { + route: 'PUT /organization-roles/:id/scopes', + event: 'OrganizationRole.Scopes.Updated', + method: 'put', + endpoint: `organization-roles/{organizationRoleId}/scopes`, + payload: { organizationScopeIds: ['{scopeId}'] }, + }, + { + route: 'DELETE /organization-roles/:id/scopes/:organizationScopeId', + event: 'OrganizationRole.Scopes.Updated', + method: 'delete', + endpoint: `organization-roles/{organizationRoleId}/scopes/{scopeId}`, + payload: {}, + }, + { + route: 'DELETE /organization-roles/:id', + event: 'OrganizationRole.Deleted', + method: 'delete', + endpoint: `organization-roles/{organizationRoleId}`, + payload: {}, + }, +]; diff --git a/packages/schemas/src/foundations/jsonb-types/hooks.ts b/packages/schemas/src/foundations/jsonb-types/hooks.ts index 502f5e207..6c014b23d 100644 --- a/packages/schemas/src/foundations/jsonb-types/hooks.ts +++ b/packages/schemas/src/foundations/jsonb-types/hooks.ts @@ -15,26 +15,62 @@ export enum InteractionHookEvent { } // DataHookEvent -// TODO: @simeng-li implement more data hook events -enum DataHookMutableSchema { +enum DataHookSchema { + User = 'User', Role = 'Role', + Scope = 'Scope', + Organization = 'Organization', + OrganizationRole = 'OrganizationRole', + OrganizationScope = 'OrganizationScope', } -enum DataHookMutationType { +enum DataHookBasicMutationType { Created = 'Created', - Updated = 'Updated', Deleted = 'Deleted', + Updated = 'Updated', } -export type DataHookEvent = `${DataHookMutableSchema}.${DataHookMutationType}`; + +type BasicDataHookEvent = `${DataHookSchema}.${DataHookBasicMutationType}`; + +// Custom DataHook mutable schemas +type CustomDataHookMutableSchema = + | `${DataHookSchema.User}.SuspensionStatus` + | `${DataHookSchema.Role}.Scopes` + | `${DataHookSchema.Organization}.Membership` + | `${DataHookSchema.OrganizationRole}.Scopes`; + +type DataHookPropertyUpdateEvent = + `${CustomDataHookMutableSchema}.${DataHookBasicMutationType.Updated}`; + +export type DataHookEvent = BasicDataHookEvent | DataHookPropertyUpdateEvent; /** The hook event values that can be registered. */ export const hookEvents = Object.freeze([ InteractionHookEvent.PostRegister, InteractionHookEvent.PostSignIn, InteractionHookEvent.PostResetPassword, + 'User.Created', + 'User.Deleted', + 'User.Updated', + 'User.SuspensionStatus.Updated', 'Role.Created', - 'Role.Updated', 'Role.Deleted', + 'Role.Updated', + 'Role.Scopes.Updated', + 'Scope.Created', + 'Scope.Deleted', + 'Scope.Updated', + 'Organization.Created', + 'Organization.Deleted', + 'Organization.Updated', + 'Organization.Membership.Updated', + 'OrganizationRole.Created', + 'OrganizationRole.Deleted', + 'OrganizationRole.Updated', + 'OrganizationRole.Scopes.Updated', + 'OrganizationScope.Created', + 'OrganizationScope.Deleted', + 'OrganizationScope.Updated', ] as const satisfies Array); /** The type of hook event values that can be registered. */ @@ -74,7 +110,34 @@ type ApiMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; * Define the hook event that should be triggered when the management API is called. */ export const managementApiHooksRegistration = Object.freeze({ + 'POST /users': 'User.Created', + 'DELETE /users/:userId': 'User.Deleted', + 'PATCH /users/:userId': 'User.Updated', + 'PATCH /users/:userId/custom-data': 'User.Updated', + 'PATCH /users/:userId/profile': 'User.Updated', + 'PATCH /users/:userId/password': 'User.Updated', + 'PATCH /users/:userId/is-suspended': 'User.SuspensionStatus.Updated', 'POST /roles': 'Role.Created', - 'PATCH /roles/:id': 'Role.Updated', 'DELETE /roles/:id': 'Role.Deleted', + 'PATCH /roles/:id': 'Role.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.Updated', + 'POST /organizations': 'Organization.Created', + 'DELETE /organizations/:id': 'Organization.Deleted', + 'PATCH /organizations/:id': 'Organization.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', + 'DELETE /organization-roles/:id': 'OrganizationRole.Deleted', + 'PATCH /organization-roles/:id': 'OrganizationRole.Updated', + 'POST /organization-scopes': 'OrganizationScope.Created', + 'DELETE /organization-scopes/:id': 'OrganizationScope.Deleted', + 'PATCH /organization-scopes/:id': 'OrganizationScope.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', } satisfies Record<`${ApiMethod} ${string}`, DataHookEvent>); diff --git a/packages/schemas/src/types/hook.ts b/packages/schemas/src/types/hook.ts index b61aaf53f..8c8179240 100644 --- a/packages/schemas/src/types/hook.ts +++ b/packages/schemas/src/types/hook.ts @@ -23,9 +23,19 @@ export type DataHookEventPayload = { hookId: string; ip?: string; userAgent?: string; - body?: Record; + /** An object that contains response data. */ + response?: { + body?: Record; + }; + /** Request route params. */ + params?: Record; + /** Request route path. */ path?: string; + /** Matched route used as the identifier to trigger the hook. */ + matchedRoute?: string; + /** Response status code. */ status?: number; + /** Request method. */ method?: string; } & Record; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 852646696..f87002d98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3767,6 +3767,9 @@ importers: typescript: specifier: ^5.3.3 version: 5.3.3 + zod: + specifier: ^3.22.4 + version: 3.22.4 packages/phrases: dependencies: