0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

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
This commit is contained in:
simeng-li 2024-05-13 16:11:50 +08:00 committed by GitHub
parent cad032a22a
commit e7a642028d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 720 additions and 18 deletions

View file

@ -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<unknown, WithHookContext> = {
...createContextWithRouteParameters(),
...ctxParams,
header: {},
appendDataHookContext: notToBeCalled,
method,
_matchedRoute: route,
path: route,
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,
},
},
],
})

View file

@ -49,13 +49,24 @@ export const koaManagementApiHooks = <StateT, ContextT extends IRouterParamConte
await next();
// Auto append pre-registered management API hooks if any
const { path, method, body, status, _matchedRoute } = ctx;
const hookRegistrationKey = buildManagementApiDataHookRegistrationKey(method, _matchedRoute);
const {
path,
method,
status,
_matchedRoute: matchedRoute,
params,
response: { body },
} = ctx;
const hookRegistrationKey = buildManagementApiDataHookRegistrationKey(method, matchedRoute);
// TODO: @simeng-li do we need to insert the request body to the hook context?
if (hasRegisteredDataHookEvent(hookRegistrationKey)) {
const event = managementApiHooksRegistration[hookRegistrationKey];
dataHooks.appendContext({ event, data: { path, method, body, status } });
dataHooks.appendContext({
event,
data: { path, method, response: { body }, status, params, matchedRoute },
});
}
if (dataHooks.contextArray.length > 0) {

View file

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

View file

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

View file

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

View file

@ -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<typeof mockHookResponseGuard>;
const hookName = 'management-api-hook';
const webhooks = new Map<string, Hook>();
const webhookResults = new Map<string, MockHookResponse>();
// 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<Hook>();
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<Role>();
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();
});
});

View file

@ -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<string, unknown>;
};
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: {},
},
];

View file

@ -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<InteractionHookEvent | DataHookEvent>);
/** 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>);

View file

@ -23,9 +23,19 @@ export type DataHookEventPayload = {
hookId: string;
ip?: string;
userAgent?: string;
/** An object that contains response data. */
response?: {
body?: Record<string, unknown>;
};
/** Request route params. */
params?: Record<string, string>;
/** 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<string, unknown>;

View file

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