0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat: init management api hook middleware function (#5783)

* feat: init management api hook middleware function

* refactor: fix type issues

* feat(core): implement auto triggered management api hooks

implement auto triggered managment api hooks

* refactor(console,core,schemas): rename the managementHook to dataHook

rename the managementHooke to dataHook and redefine the types

* feat(core): add dev feature guard

add dev feature guard

* chore: update changeset

update changeset

* refactor(core,console,schemas,shared): update the webhook logics

update the webhook logics. Address some PR review comments

* fix(test): fix integration tests

fix integration tests

* fix(test): remove legacy code

remove legacy code

* refactor(core,schemas): refactor the hook library code

refactor the webhooks library code. address some comments

* fix(core): address rebase issue

update console log using getConsoleLogFromContext

* fix(core): fix ut

fix ut

* fix(core): refactor data webhook code

refactor data webhook codes

* refactor(core): clean up some management api webhook code

clean up some management api webhook code

---------

Co-authored-by: simeng-li <simeng@silverhand.io>
This commit is contained in:
Gao Sun 2024-05-09 11:19:01 +08:00 committed by GitHub
parent 0d8d3a0d95
commit 21bb35b127
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 768 additions and 191 deletions

View file

@ -0,0 +1,10 @@
---
"@logto/schemas": minor
"@logto/core": minor
"@logto/console": minor
---
refactor the definition of hook event types
- Add `DataHook` event types. `DataHook` are triggered by data changes.
- Add "interaction" prefix to existing hook event types. Interaction hook events are triggered by end user interactions, e.g. completing sign-in.

View file

@ -0,0 +1,7 @@
---
"@logto/shared": patch
---
add `normalizeError` method to `@logto/shared` package
Use this method to normalize error objects for logging. This method is useful for logging errors in a consistent format.

View file

@ -1,4 +1,4 @@
import { HookEvent, type Hook, type HookConfig } from '@logto/schemas';
import { type HookEvent, type Hook, type HookConfig, InteractionHookEvent } from '@logto/schemas';
import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
@ -10,7 +10,8 @@ import { uriValidator } from '@/utils/validator';
import * as styles from './index.module.scss';
const hookEventOptions = Object.values(HookEvent).map((event) => ({
// TODO: Implement all hook events
const hookEventOptions = Object.values(InteractionHookEvent).map((event) => ({
title: hookEventLabel[event],
value: event,
}));

View file

@ -1,22 +1,24 @@
import { type AdminConsoleKey } from '@logto/phrases';
import { HookEvent, type LogKey } from '@logto/schemas';
import { InteractionHookEvent, type LogKey } from '@logto/schemas';
type HookEventLabel = {
[key in HookEvent]: AdminConsoleKey;
// TODO: Implement all hook events
[key in InteractionHookEvent]: AdminConsoleKey;
};
export const hookEventLabel = Object.freeze({
[HookEvent.PostRegister]: 'webhooks.events.post_register',
[HookEvent.PostResetPassword]: 'webhooks.events.post_reset_password',
[HookEvent.PostSignIn]: 'webhooks.events.post_sign_in',
[InteractionHookEvent.PostRegister]: 'webhooks.events.post_register',
[InteractionHookEvent.PostResetPassword]: 'webhooks.events.post_reset_password',
[InteractionHookEvent.PostSignIn]: 'webhooks.events.post_sign_in',
}) satisfies HookEventLabel;
type HookEventLogKey = {
[key in HookEvent]: LogKey;
// TODO: Implement all hook events
[key in InteractionHookEvent]: LogKey;
};
export const hookEventLogKey = Object.freeze({
[HookEvent.PostRegister]: 'TriggerHook.PostRegister',
[HookEvent.PostResetPassword]: 'TriggerHook.PostResetPassword',
[HookEvent.PostSignIn]: 'TriggerHook.PostSignIn',
[InteractionHookEvent.PostRegister]: 'TriggerHook.PostRegister',
[InteractionHookEvent.PostResetPassword]: 'TriggerHook.PostResetPassword',
[InteractionHookEvent.PostSignIn]: 'TriggerHook.PostSignIn',
}) satisfies HookEventLogKey;

View file

@ -1,4 +1,4 @@
import { type Log, HookEvent } from '@logto/schemas';
import { type Log, InteractionHookEvent } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
@ -21,7 +21,8 @@ import { type WebhookDetailsOutletContext } from '../types';
import * as styles from './index.module.scss';
const hookLogEventOptions = Object.values(HookEvent).map((event) => ({
// TODO: Implement all hook events
const hookLogEventOptions = Object.values(InteractionHookEvent).map((event) => ({
title: <DynamicT forKey={hookEventLabel[event]} />,
value: hookEventLogKey[event],
}));
@ -96,7 +97,10 @@ function WebhookLogs() {
dataIndex: 'event',
colSpan: 6,
render: ({ key }) => {
const event = Object.values(HookEvent).find((event) => hookEventLogKey[event] === key);
// TODO: Implement all hook events
const event = Object.values(InteractionHookEvent).find(
(event) => hookEventLogKey[event] === key
);
return conditional(event && t(hookEventLabel[event])) ?? '-';
},
},

View file

@ -1,4 +1,4 @@
import { type HookEvent, type Hook, Theme, type HookResponse } from '@logto/schemas';
import { type Hook, Theme, type HookResponse, type InteractionHookEvent } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
@ -93,8 +93,9 @@ function Webhooks() {
const eventArray = conditional(events.length > 0 && events) ?? [event];
return (
eventArray
// TODO: Implement all hook events
// eslint-disable-next-line unicorn/prefer-native-coercion-functions
.filter((_event): _event is HookEvent => Boolean(_event))
.filter((_event): _event is InteractionHookEvent => Boolean(_event))
.map((_event) => t(hookEventLabel[_event]))
.join(', ')
);

View file

@ -79,6 +79,7 @@
"oidc-provider": "^8.4.6",
"openapi-types": "^12.1.3",
"otplib": "^12.0.1",
"p-map": "^7.0.2",
"p-retry": "^6.0.0",
"pg-protocol": "^1.6.0",
"pluralize": "^8.0.0",

View file

@ -1,4 +1,4 @@
import { type Hook, HookEvent } from '@logto/schemas';
import { type Hook, InteractionHookEvent } from '@logto/schemas';
export const mockNanoIdForHook = 'random_string';
@ -11,7 +11,7 @@ export const mockHook: Hook = {
id: mockNanoIdForHook,
name: 'foo',
event: null,
events: [HookEvent.PostRegister],
events: [InteractionHookEvent.PostRegister],
config: {
url: 'https://example.com',
},
@ -25,7 +25,7 @@ const mockHookData1: Hook = {
id: 'hook_id_1',
name: 'foo',
event: null,
events: [HookEvent.PostRegister],
events: [InteractionHookEvent.PostRegister],
config: {
url: 'https://example1.com',
},
@ -39,7 +39,7 @@ const mockHookData2: Hook = {
id: 'hook_id_2',
name: 'bar',
event: null,
events: [HookEvent.PostResetPassword],
events: [InteractionHookEvent.PostResetPassword],
config: {
url: 'https://example2.com',
},
@ -53,7 +53,7 @@ const mockHookData3: Hook = {
id: 'hook_id_3',
name: 'baz',
event: null,
events: [HookEvent.PostSignIn],
events: [InteractionHookEvent.PostSignIn],
config: {
url: 'https://example3.com',
},

View file

@ -0,0 +1,24 @@
import { type DataHookEvent } from '@logto/schemas';
type DataHookContext = {
event: DataHookEvent;
data?: Record<string, unknown>;
};
type DataHookMetadata = {
userAgent?: string;
ip: string;
};
export class DataHookContextManager {
contextArray: DataHookContext[] = [];
constructor(public metadata: DataHookMetadata) {}
appendContext({ event, data }: DataHookContext) {
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
this.contextArray.push({ event, data });
}
}
// TODO: @simeng-li migrate the current interaction hook context using hook context manager

View file

@ -1,11 +1,12 @@
import type { Hook } from '@logto/schemas';
import { HookEvent, InteractionEvent, LogResult } from '@logto/schemas';
import { InteractionEvent, InteractionHookEvent, LogResult } from '@logto/schemas';
import { ConsoleLog } from '@logto/shared';
import { createMockUtils } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import { mockId, mockIdGenerators } from '#src/test-utils/nanoid.js';
import { DataHookContextManager } from './context-manager.js';
import { generateHookTestPayload, parseResponse } from './utils.js';
const { jest } = import.meta;
@ -29,26 +30,39 @@ const { sendWebhookRequest } = mockEsm('./utils.js', () => ({
const { MockQueries } = await import('#src/test-utils/tenant.js');
const url = 'https://logto.gg';
const hook: Hook = {
tenantId: 'bar',
id: 'foo',
name: 'hook_name',
event: HookEvent.PostSignIn,
events: [HookEvent.PostSignIn],
event: InteractionHookEvent.PostSignIn,
events: [InteractionHookEvent.PostSignIn],
signingKey: 'signing_key',
enabled: true,
config: { headers: { bar: 'baz' }, url, retries: 3 },
createdAt: Date.now() / 1000,
};
const dataHook: Hook = {
tenantId: 'bar',
id: 'foo',
name: 'hook_name',
event: 'Role.Created',
events: ['Role.Created'],
enabled: true,
signingKey: 'signing_key',
config: { headers: { bar: 'baz' }, url, retries: 3 },
createdAt: Date.now() / 1000,
};
const insertLog = jest.fn();
const mockHookState = { requestCount: 100, successCount: 10 };
const getHookExecutionStatsByHookId = jest.fn().mockResolvedValue(mockHookState);
const findAllHooks = jest.fn().mockResolvedValue([hook]);
const findAllHooks = jest.fn().mockResolvedValue([hook, dataHook]);
const findHookById = jest.fn().mockResolvedValue(hook);
const { createHookLibrary } = await import('./index.js');
const { triggerInteractionHooks, testHook } = createHookLibrary(
const { triggerInteractionHooks, triggerTestHook, triggerDataHooks } = createHookLibrary(
new MockQueries({
users: {
findUserById: jest.fn().mockReturnValue({
@ -97,10 +111,13 @@ describe('triggerInteractionHooks()', () => {
const calledPayload: unknown = insertLog.mock.calls[0][0];
expect(calledPayload).toHaveProperty('id', mockId);
expect(calledPayload).toHaveProperty('key', 'TriggerHook.' + HookEvent.PostSignIn);
expect(calledPayload).toHaveProperty('key', 'TriggerHook.' + InteractionHookEvent.PostSignIn);
expect(calledPayload).toHaveProperty('payload.result', LogResult.Success);
expect(calledPayload).toHaveProperty('payload.hookId', 'foo');
expect(calledPayload).toHaveProperty('payload.hookRequest.body.event', HookEvent.PostSignIn);
expect(calledPayload).toHaveProperty(
'payload.hookRequest.body.event',
InteractionHookEvent.PostSignIn
);
expect(calledPayload).toHaveProperty(
'payload.hookRequest.body.interactionEvent',
InteractionEvent.SignIn
@ -113,7 +130,7 @@ describe('triggerInteractionHooks()', () => {
});
});
describe('testHook', () => {
describe('triggerTestHook', () => {
afterEach(() => {
jest.clearAllMocks();
});
@ -121,11 +138,14 @@ describe('testHook', () => {
it('should call sendWebhookRequest with correct values', async () => {
jest.useFakeTimers().setSystemTime(100_000);
await testHook(hook.id, [HookEvent.PostSignIn], hook.config);
const testHookPayload = generateHookTestPayload(hook.id, HookEvent.PostSignIn);
await triggerTestHook(hook.id, [InteractionHookEvent.PostSignIn], hook.config);
const triggerTestHookPayload = generateHookTestPayload(
hook.id,
InteractionHookEvent.PostSignIn
);
expect(sendWebhookRequest).toHaveBeenCalledWith({
hookConfig: hook.config,
payload: testHookPayload,
payload: triggerTestHookPayload,
signingKey: hook.signingKey,
});
@ -133,13 +153,19 @@ describe('testHook', () => {
});
it('should call sendWebhookRequest with correct times if multiple events are provided', async () => {
await testHook(hook.id, [HookEvent.PostSignIn, HookEvent.PostResetPassword], hook.config);
await triggerTestHook(
hook.id,
[InteractionHookEvent.PostSignIn, InteractionHookEvent.PostResetPassword],
hook.config
);
expect(sendWebhookRequest).toBeCalledTimes(2);
});
it('should throw send test payload failed error if sendWebhookRequest fails', async () => {
sendWebhookRequest.mockRejectedValueOnce(new Error('test error'));
await expect(testHook(hook.id, [HookEvent.PostSignIn], hook.config)).rejects.toThrowError(
await expect(
triggerTestHook(hook.id, [InteractionHookEvent.PostSignIn], hook.config)
).rejects.toThrowError(
new RequestError({
code: 'hook.send_test_payload_failed',
message: 'Error: test error',
@ -148,3 +174,62 @@ describe('testHook', () => {
);
});
});
describe('triggerDataHooks()', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should set correct payload when hook triggered', async () => {
jest.useFakeTimers().setSystemTime(100_000);
const metadata = { userAgent: 'ua', ip: 'ip' };
const hookData = { path: '/test', method: 'POST', body: { success: true } };
const hooksManager = new DataHookContextManager(metadata);
hooksManager.appendContext({
event: 'Role.Created',
data: hookData,
});
await triggerDataHooks(new ConsoleLog(), hooksManager);
expect(findAllHooks).toHaveBeenCalled();
expect(sendWebhookRequest).toHaveBeenCalledWith({
hookConfig: dataHook.config,
payload: {
hookId: 'foo',
event: 'Role.Created',
createdAt: new Date(100_000).toISOString(),
...hookData,
...metadata,
},
signingKey: dataHook.signingKey,
});
const calledPayload: unknown = insertLog.mock.calls[0][0];
expect(calledPayload).toMatchObject({
id: mockId,
key: 'TriggerHook.Role.Created',
payload: {
result: LogResult.Success,
hookId: 'foo',
hookRequest: {
body: {
event: 'Role.Created',
hookId: 'foo',
...hookData,
},
},
response: {
statusCode: 200,
body: { message: 'ok' },
},
},
});
jest.useRealTimers();
});
});

View file

@ -1,48 +1,36 @@
import {
HookEvent,
type HookEventPayload,
InteractionEvent,
LogResult,
userInfoSelectFields,
type DataHookEventPayload,
type Hook,
type HookConfig,
type HookEvent,
type HookEventPayload,
type HookTestErrorResponseData,
type InteractionHookEventPayload,
} from '@logto/schemas';
import { type ConsoleLog, generateStandardId } from '@logto/shared';
import { generateStandardId, normalizeError, type ConsoleLog } from '@logto/shared';
import { conditional, pick, trySafe } from '@silverhand/essentials';
import { HTTPError } from 'ky';
import pMap from 'p-map';
import RequestError from '#src/errors/RequestError/index.js';
import { LogEntry } from '#src/middleware/koa-audit-log.js';
import type Queries from '#src/tenants/Queries.js';
import { type DataHookContextManager } from './context-manager.js';
import {
interactionEventToHookEvent,
type InteractionHookContext,
type InteractionHookResult,
} from './types.js';
import { generateHookTestPayload, parseResponse, sendWebhookRequest } from './utils.js';
/**
* The context for triggering interaction hooks by `triggerInteractionHooks`.
* In the `koaInteractionHooks` middleware,
* we will store the context before processing the interaction and consume it after the interaction is processed if needed.
*/
export type InteractionHookContext = {
event: InteractionEvent;
sessionId?: string;
applicationId?: string;
userIp?: string;
type BetterOmit<T, Ignore> = {
[key in keyof T as key extends Ignore ? never : key]: T[key];
};
/**
* The interaction hook result for triggering interaction hooks by `triggerInteractionHooks`.
* In the `koaInteractionHooks` middleware,
* if we get an interaction hook result after the interaction is processed, related hooks will be triggered.
*/
export type InteractionHookResult = {
userId: string;
};
const eventToHook: Record<InteractionEvent, HookEvent> = {
[InteractionEvent.Register]: HookEvent.PostRegister,
[InteractionEvent.SignIn]: HookEvent.PostSignIn,
[InteractionEvent.ForgotPassword]: HookEvent.PostResetPassword,
};
type HookEventPayloadWithoutHookId = BetterOmit<HookEventPayload, 'hookId'>;
export const createHookLibrary = (queries: Queries) => {
const {
@ -53,6 +41,66 @@ export const createHookLibrary = (queries: Queries) => {
hooks: { findAllHooks, findHookById },
} = queries;
/**
* Trigger web hook with the given payload and create a log entry for the request and response.
*/
const sendWebhook = async (
hook: Hook,
payload: HookEventPayloadWithoutHookId,
consoleLog: ConsoleLog
) => {
const { id, config, signingKey } = hook;
consoleLog.info(`\tTriggering hook ${id} due to ${payload.event} event`);
const json: HookEventPayload = { ...payload, hookId: id };
const logEntry = new LogEntry(`TriggerHook.${payload.event}`);
logEntry.append({ hookId: id, hookRequest: { body: json } });
// Trigger web hook and log response
try {
const response = await sendWebhookRequest({
hookConfig: config,
payload: json,
signingKey,
});
logEntry.append({
response: await parseResponse(response),
});
} catch (error: unknown) {
logEntry.append({
result: LogResult.Error,
response: conditional(error instanceof HTTPError && (await parseResponse(error.response))),
error: String(normalizeError(error)),
});
}
consoleLog.info(
`\tHook ${id} ${logEntry.payload.result === LogResult.Success ? 'succeeded' : 'failed'}`
);
await insertLog({
id: generateStandardId(),
key: logEntry.key,
payload: logEntry.payload,
});
};
/**
* Trigger multiple web hooks with concurrency control.
*/
const sendWebhooks = async <T extends HookEventPayloadWithoutHookId>(
webhooks: Array<{ hook: Hook; payload: T }>,
consoleLog: ConsoleLog
) =>
pMap(webhooks, async ({ hook, payload }) => sendWebhook(hook, payload, consoleLog), {
concurrency: 10,
});
/**
* Trigger interaction hooks with the given interaction context and result.
*/
const triggerInteractionHooks = async (
consoleLog: ConsoleLog,
interactionContext: InteractionHookContext,
@ -62,14 +110,14 @@ export const createHookLibrary = (queries: Queries) => {
const { userId } = interactionResult;
const { event, sessionId, applicationId, userIp } = interactionContext;
const hookEvent = eventToHook[event];
const hookEvent = interactionEventToHookEvent[event];
const found = await findAllHooks();
const rows = found.filter(
const hooks = found.filter(
({ event, events, enabled }) =>
enabled && (events.length > 0 ? events.includes(hookEvent) : event === hookEvent) // For backward compatibility
);
if (rows.length === 0) {
if (hooks.length === 0) {
return;
}
@ -88,51 +136,48 @@ export const createHookLibrary = (queries: Queries) => {
userIp,
user: user && pick(user, ...userInfoSelectFields),
application: application && pick(application, 'id', 'type', 'name', 'description'),
} satisfies Omit<HookEventPayload, 'hookId'>;
} satisfies BetterOmit<InteractionHookEventPayload, 'hookId'>;
await Promise.all(
rows.map(async ({ id, config, signingKey }) => {
consoleLog.info(`\tTriggering hook ${id} due to ${hookEvent} event`);
const json: HookEventPayload = { hookId: id, ...payload };
const logEntry = new LogEntry(`TriggerHook.${hookEvent}`);
logEntry.append({ hookId: id, hookRequest: { body: json } });
// Trigger web hook and log response
await sendWebhookRequest({
hookConfig: config,
payload: json,
signingKey,
})
.then(async (response) => {
logEntry.append({
response: await parseResponse(response),
});
})
.catch(async (error) => {
logEntry.append({
result: LogResult.Error,
response: conditional(
error instanceof HTTPError && (await parseResponse(error.response))
),
error: conditional(error instanceof Error && String(error)),
});
});
consoleLog.info(
`\tHook ${id} ${logEntry.payload.result === LogResult.Success ? 'succeeded' : 'failed'}`
);
await insertLog({
id: generateStandardId(),
key: logEntry.key,
payload: logEntry.payload,
});
})
await sendWebhooks(
hooks.map((hook) => ({ hook, payload })),
consoleLog
);
};
const testHook = async (hookId: string, events: HookEvent[], config: HookConfig) => {
/**
* Trigger data hooks with the given data mutation context. All context objects will be used to trigger hooks.
*/
const triggerDataHooks = async (
consoleLog: ConsoleLog,
contextManager: DataHookContextManager
) => {
if (contextManager.contextArray.length === 0) {
return;
}
const found = await findAllHooks();
// Filter hooks that match each events
const webhooks = contextManager.contextArray.flatMap(({ event, data }) => {
const hooks = found.filter(
({ event: hookEvent, events, enabled }) =>
enabled && (events.length > 0 ? events.includes(event) : event === hookEvent)
);
const payload = {
event,
createdAt: new Date().toISOString(),
...contextManager.metadata,
...data,
} satisfies BetterOmit<DataHookEventPayload, 'hookId'>;
return hooks.map((hook) => ({ hook, payload }));
});
await sendWebhooks(webhooks, consoleLog);
};
const triggerTestHook = async (hookId: string, events: HookEvent[], config: HookConfig) => {
const { signingKey } = await findHookById(hookId);
try {
await Promise.all(
@ -169,6 +214,7 @@ export const createHookLibrary = (queries: Queries) => {
return {
triggerInteractionHooks,
testHook,
triggerDataHooks,
triggerTestHook,
};
};

View file

@ -0,0 +1,28 @@
import { InteractionEvent, InteractionHookEvent } from '@logto/schemas';
/**
* The context for triggering interaction hooks by `triggerInteractionHooks`.
* In the `koaInteractionHooks` middleware,
* we will store the context before processing the interaction and consume it after the interaction is processed if needed.
*/
export type InteractionHookContext = {
event: InteractionEvent;
sessionId?: string;
applicationId?: string;
userIp?: string;
};
/**
* The interaction hook result for triggering interaction hooks by `triggerInteractionHooks`.
* In the `koaInteractionHooks` middleware,
* if we get an interaction hook result after the interaction is processed, related hooks will be triggered.
*/
export type InteractionHookResult = {
userId: string;
};
export const interactionEventToHookEvent: Record<InteractionEvent, InteractionHookEvent> = {
[InteractionEvent.Register]: InteractionHookEvent.PostRegister,
[InteractionEvent.SignIn]: InteractionHookEvent.PostSignIn,
[InteractionEvent.ForgotPassword]: InteractionHookEvent.PostResetPassword,
};

View file

@ -1,4 +1,4 @@
import { HookEvent } from '@logto/schemas';
import { type HookEvent, InteractionHookEvent } from '@logto/schemas';
import { createMockUtils } from '@logto/shared/esm';
import ky from 'ky';
@ -21,7 +21,7 @@ const { generateHookTestPayload, sendWebhookRequest } = await import('./utils.js
describe('sendWebhookRequest', () => {
it('should call got.post with correct values', async () => {
const mockHookId = 'mockHookId';
const mockEvent: HookEvent = HookEvent.PostSignIn;
const mockEvent: HookEvent = InteractionHookEvent.PostSignIn;
const testPayload = generateHookTestPayload(mockHookId, mockEvent);
const mockUrl = 'https://logto.gg';

View file

@ -1,10 +1,12 @@
import {
ApplicationType,
managementApiHooksRegistration,
type HookConfig,
type HookEvent,
type HookEventPayload,
ApplicationType,
type HookConfig,
} from '@logto/schemas';
import { conditional, trySafe } from '@silverhand/essentials';
import { type IRouterParamContext } from 'koa-router';
import ky, { type KyResponse } from 'ky';
import { sign } from '#src/utils/sign.js';
@ -83,3 +85,12 @@ export const generateHookTestPayload = (hookId: string, event: HookEvent): HookE
},
};
};
export const buildManagementApiDataHookRegistrationKey = (
method: string,
route: IRouterParamContext['_matchedRoute']
) => `${method} ${route}`;
export const hasRegisteredDataHookEvent = (
key: string
): key is keyof typeof managementApiHooksRegistration => key in managementApiHooksRegistration;

View file

@ -0,0 +1,95 @@
import { managementApiHooksRegistration } from '@logto/schemas';
import { ConsoleLog } from '@logto/shared';
import { type ParameterizedContext } from 'koa';
import type Libraries from '#src/tenants/Libraries.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import { koaManagementApiHooks, type WithHookContext } from './koa-management-api-hooks.js';
const { jest } = import.meta;
const notToBeCalled = () => {
throw new Error('Should not be called');
};
describe('koaManagementApiHooks', () => {
const next = jest.fn();
const triggerDataHooks = jest.fn();
// @ts-expect-error mock
const mockHooksLibrary: Libraries['hooks'] = {
triggerDataHooks,
};
it("should do nothing if there's no hook context", async () => {
const ctx = {
...createContextWithRouteParameters(),
header: {},
appendDataHookContext: notToBeCalled,
};
await koaManagementApiHooks(mockHooksLibrary)(ctx, next);
expect(triggerDataHooks).not.toBeCalled();
});
it('should trigger management hooks', async () => {
const ctx: ParameterizedContext<unknown, WithHookContext> = {
...createContextWithRouteParameters(),
header: {},
appendDataHookContext: notToBeCalled,
};
next.mockImplementation(() => {
ctx.appendDataHookContext({ event: 'Role.Created', data: { id: '123' } });
});
await koaManagementApiHooks(mockHooksLibrary)(ctx, next);
expect(triggerDataHooks).toBeCalledTimes(1);
expect(triggerDataHooks).toBeCalledWith(
expect.any(ConsoleLog),
expect.objectContaining({
contextArray: [
{
event: 'Role.Created',
data: { id: '123' },
},
],
})
);
});
describe('auto append pre-registered management API hooks', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const events = Object.entries(managementApiHooksRegistration);
it.each(events)('should append hook context for %s', async (key, event) => {
const [method, route] = key.split(' ') as [string, string];
const ctx: ParameterizedContext<unknown, WithHookContext> = {
...createContextWithRouteParameters(),
header: {},
appendDataHookContext: notToBeCalled,
method,
_matchedRoute: route,
path: route,
body: { key },
status: 200,
};
await koaManagementApiHooks(mockHooksLibrary)(ctx, next);
expect(triggerDataHooks).toBeCalledWith(
expect.any(ConsoleLog),
expect.objectContaining({
contextArray: [
{
event,
data: { path: route, method, body: { key }, status: 200 },
},
],
})
);
});
});
});

View file

@ -0,0 +1,66 @@
import { managementApiHooksRegistration } from '@logto/schemas';
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 {
buildManagementApiDataHookRegistrationKey,
hasRegisteredDataHookEvent,
} from '#src/libraries/hook/utils.js';
import type Libraries from '#src/tenants/Libraries.js';
import { getConsoleLogFromContext } from '#src/utils/console.js';
export type WithHookContext<ContextT extends IRouterParamContext = IRouterParamContext> =
ContextT & { appendDataHookContext: DataHookContextManager['appendContext'] };
/**
* The factory to create a new management hook middleware function.
*
* To trigger management hooks, use `appendDataHookContext` to append the context.
*
* @param hooks The hooks library.
* @returns The middleware function.
*/
export const koaManagementApiHooks = <StateT, ContextT extends IRouterParamContext, ResponseT>(
hooks: Libraries['hooks']
): MiddlewareType<StateT, WithHookContext<ContextT>, ResponseT> => {
return async (ctx, next) => {
// TODO: Remove dev feature guard
const { isDevFeaturesEnabled } = EnvSet.values;
if (!isDevFeaturesEnabled) {
return;
}
const {
header: { 'user-agent': userAgent },
ip,
} = ctx;
const dataHooks = new DataHookContextManager({ userAgent, ip });
/**
* Append a hook context to trigger management hooks. If multiple contexts are appended, all of
* them will be triggered.
*/
ctx.appendDataHookContext = dataHooks.appendContext.bind(dataHooks);
await next();
// Auto append pre-registered management API hooks if any
const { path, method, body, status, _matchedRoute } = 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 } });
}
if (dataHooks.contextArray.length > 0) {
// Hooks should not crash the app
void trySafe(hooks.triggerDataHooks(getConsoleLogFromContext(ctx), dataHooks));
}
};
};

View file

@ -1,12 +1,12 @@
import {
HookEvent,
type Hook,
type HookEvents,
type HookConfig,
type CreateHook,
InteractionHookEvent,
LogResult,
type Log,
hook,
type CreateHook,
type Hook,
type HookConfig,
type HookEvents,
type Log,
} from '@logto/schemas';
import { pickDefault } from '@logto/shared/esm';
import { subDays } from 'date-fns';
@ -76,10 +76,10 @@ const mockQueries = {
logs,
};
const testHook = jest.fn();
const triggerTestHook = jest.fn();
const mockLibraries = {
hooks: { testHook },
hooks: { triggerTestHook },
quota: createMockQuotaLibrary(),
};
@ -165,7 +165,7 @@ describe('hook routes', () => {
it('POST /hooks', async () => {
const name = 'fooName';
const events: HookEvents = [HookEvent.PostRegister];
const events: HookEvents = [InteractionHookEvent.PostRegister];
const config: HookConfig = {
url: 'https://example.com',
};
@ -187,7 +187,7 @@ describe('hook routes', () => {
it('POST /hooks should be able to create a hook with multi events', async () => {
const name = 'anyName';
const events: HookEvents = [HookEvent.PostSignIn, HookEvent.PostRegister];
const events: HookEvents = [InteractionHookEvent.PostSignIn, InteractionHookEvent.PostRegister];
const config: HookConfig = {
url: 'https://example.com',
};
@ -219,7 +219,7 @@ describe('hook routes', () => {
it('POST /hooks should success when create a hook with the old payload format', async () => {
const payload: Partial<Hook> = {
event: HookEvent.PostRegister,
event: InteractionHookEvent.PostRegister,
config: {
url: 'https://example.com',
retries: 2,
@ -232,7 +232,7 @@ describe('hook routes', () => {
expect(response.body).toMatchObject({
tenantId: mockTenantIdForHook,
id: generatedId,
event: HookEvent.PostRegister,
event: InteractionHookEvent.PostRegister,
config: {
url: 'https://example.com',
retries: 2,
@ -243,7 +243,7 @@ describe('hook routes', () => {
it('POST /hooks/:id/test should return 204 if test is successful', async () => {
const targetMockHook = mockHookList[0] ?? mockHook;
const response = await hookRequest.post(`/hooks/${targetMockHook.id}/test`).send({
events: [HookEvent.PostRegister],
events: [InteractionHookEvent.PostRegister],
config: { url: 'https://example.com' },
});
expect(response.status).toEqual(204);
@ -252,7 +252,7 @@ describe('hook routes', () => {
it('PATCH /hooks/:id', async () => {
const targetMockHook = mockHookList[0] ?? mockHook;
const name = 'newName';
const events: HookEvents = [HookEvent.PostSignIn];
const events: HookEvents = [InteractionHookEvent.PostSignIn];
const config: HookConfig = {
url: 'https://new.com',
};
@ -272,7 +272,7 @@ describe('hook routes', () => {
it('PATCH /hooks/:id should success when update a hook with multi events', async () => {
const targetMockHook = mockHookList[0] ?? mockHook;
const events = [HookEvent.PostSignIn, HookEvent.PostResetPassword];
const events = [InteractionHookEvent.PostSignIn, InteractionHookEvent.PostResetPassword];
const response = await hookRequest.patch(`/hooks/${targetMockHook.id}`).send({ events });
expect(response.status).toEqual(200);
@ -283,7 +283,7 @@ describe('hook routes', () => {
it('PATCH /hooks/:id should success when update a hook with the old payload format', async () => {
const targetMockHook = mockHookList[0] ?? mockHook;
const event = HookEvent.PostSignIn;
const event = InteractionHookEvent.PostSignIn;
const response = await hookRequest.patch(`/hooks/${targetMockHook.id}`).send({
event,
config: {
@ -303,7 +303,7 @@ describe('hook routes', () => {
});
it('PATCH /hooks/:id with empty events list should fail', async () => {
const invalidEvents: HookEvent[] = [];
const invalidEvents: InteractionHookEvent[] = [];
const response = await hookRequest
.patch(`/hooks/${mockNanoIdForHook}`)
.send({ events: invalidEvents });

View file

@ -1,12 +1,12 @@
import {
Hooks,
Logs,
hook,
hookConfigGuard,
hookEventsGuard,
hookResponseGuard,
hook,
type HookResponse,
type Hook,
type HookResponse,
} from '@logto/schemas';
import { generateStandardId, generateStandardSecret } from '@logto/shared';
import { conditional, deduplicate, yes } from '@silverhand/essentials';
@ -42,7 +42,7 @@ export default function hookRoutes<T extends ManagementApiRouter>(
} = queries;
const {
hooks: { testHook },
hooks: { triggerTestHook },
quota,
} = libraries;
@ -196,7 +196,7 @@ export default function hookRoutes<T extends ManagementApiRouter>(
body: { events, config },
} = ctx.guard;
await testHook(id, events, config);
await triggerTestHook(id, events, config);
ctx.status = 204;

View file

@ -5,6 +5,7 @@ import Router from 'koa-router';
import { EnvSet } from '#src/env-set/index.js';
import koaBodyEtag from '#src/middleware/koa-body-etag.js';
import koaCors from '#src/middleware/koa-cors.js';
import { koaManagementApiHooks } from '#src/middleware/koa-management-api-hooks.js';
import koaTenantGuard from '#src/middleware/koa-tenant-guard.js';
import type TenantContext from '#src/tenants/TenantContext.js';
@ -48,6 +49,7 @@ const createRouters = (tenant: TenantContext) => {
const managementRouter: ManagementApiRouter = new Router();
managementRouter.use(koaAuth(tenant.envSet, getManagementApiResourceIndicator(tenant.id)));
managementRouter.use(koaTenantGuard(tenant.id, tenant.queries));
managementRouter.use(koaManagementApiHooks(tenant.libraries.hooks));
applicationRoutes(managementRouter, tenant);
applicationRoleRoutes(managementRouter, tenant);

View file

@ -1,11 +1,11 @@
import { type Optional, trySafe, conditionalString } from '@silverhand/essentials';
import { conditionalString, trySafe, type Optional } from '@silverhand/essentials';
import type { MiddlewareType } from 'koa';
import type { IRouterParamContext } from 'koa-router';
import {
type InteractionHookContext,
type InteractionHookResult,
} from '#src/libraries/hook/index.js';
} from '#src/libraries/hook/types.js';
import type Libraries from '#src/tenants/Libraries.js';
import { getConsoleLogFromContext } from '#src/utils/console.js';
@ -33,6 +33,7 @@ export default function koaInteractionHooks<
}: Libraries): MiddlewareType<StateT, WithInteractionHooksContext<ContextT>, ResponseT> {
return async (ctx, next) => {
const { event } = getInteractionStorage(ctx.interactionDetails.result);
const {
interactionDetails,
header: { 'user-agent': userAgent },
@ -60,6 +61,8 @@ export default function koaInteractionHooks<
interactionHookResult = result;
};
// TODO: @simeng-li Add DataHookContext to the interaction hook middleware as well
await next();
if (interactionHookResult) {

View file

@ -4,6 +4,7 @@ import type Router from 'koa-router';
import type { WithLogContext } from '#src/middleware/koa-audit-log.js';
import type { WithAuthContext } from '#src/middleware/koa-auth/index.js';
import type { WithI18nContext } from '#src/middleware/koa-i18next.js';
import { type WithHookContext } from '#src/middleware/koa-management-api-hooks.js';
import type TenantContext from '#src/tenants/TenantContext.js';
export type AnonymousRouter = Router<unknown, WithLogContext & WithI18nContext>;
@ -11,6 +12,7 @@ export type AnonymousRouter = Router<unknown, WithLogContext & WithI18nContext>;
type ManagementApiRouterContext = WithAuthContext &
WithLogContext &
WithI18nContext &
WithHookContext &
ExtendableContext;
export type ManagementApiRouter = Router<unknown, ManagementApiRouterContext>;

View file

@ -1,10 +1,10 @@
import {
type Hook,
HookEvent,
type HookResponse,
type Log,
LogResult,
SignInIdentifier,
InteractionHookEvent,
} from '@logto/schemas';
import { deleteUser } from '#src/api/admin-user.js';
@ -34,7 +34,7 @@ describe('hook logs', () => {
it('should get recent hook logs correctly', async () => {
const createdHook = await authedAdminApi
.post('hooks', {
json: getHookCreationPayload(HookEvent.PostRegister, 'http://localhost:9999'),
json: getHookCreationPayload(InteractionHookEvent.PostRegister, 'http://localhost:9999'),
})
.json<Hook>();
@ -59,7 +59,7 @@ describe('hook logs', () => {
it('should get hook execution stats correctly', async () => {
const createdHook = await authedAdminApi
.post('hooks', {
json: getHookCreationPayload(HookEvent.PostRegister, 'http://localhost:9999'),
json: getHookCreationPayload(InteractionHookEvent.PostRegister, 'http://localhost:9999'),
})
.json<Hook>();

View file

@ -1,5 +1,5 @@
import type { Hook } from '@logto/schemas';
import { HookEvent } from '@logto/schemas';
import { InteractionHookEvent } from '@logto/schemas';
import { authedAdminApi } from '#src/api/index.js';
import { getHookCreationPayload } from '#src/helpers/hook.js';
@ -7,7 +7,7 @@ import { expectRejects } from '#src/helpers/index.js';
describe('hooks', () => {
it('should be able to create, query, update, and delete a hook', async () => {
const payload = getHookCreationPayload(HookEvent.PostRegister);
const payload = getHookCreationPayload(InteractionHookEvent.PostRegister);
const created = await authedAdminApi.post('hooks', { json: payload }).json<Hook>();
expect(created).toMatchObject(payload);
@ -16,9 +16,9 @@ describe('hooks', () => {
expect(await authedAdminApi.get(`hooks/${created.id}`).json<Hook>()).toEqual(created);
expect(
await authedAdminApi
.patch(`hooks/${created.id}`, { json: { events: [HookEvent.PostSignIn] } })
.patch(`hooks/${created.id}`, { json: { events: [InteractionHookEvent.PostSignIn] } })
.json<Hook>()
).toMatchObject({ ...created, events: [HookEvent.PostSignIn] });
).toMatchObject({ ...created, events: [InteractionHookEvent.PostSignIn] });
expect(await authedAdminApi.delete(`hooks/${created.id}`)).toHaveProperty('status', 204);
await expectRejects(authedAdminApi.get(`hooks/${created.id}`), {
code: 'entity.not_exists_with_id',
@ -28,7 +28,7 @@ describe('hooks', () => {
it('should be able to create, query, update, and delete a hook by the original API', async () => {
const payload = {
event: HookEvent.PostRegister,
event: InteractionHookEvent.PostRegister,
config: {
url: 'not_work_url',
retries: 2,
@ -42,11 +42,11 @@ describe('hooks', () => {
expect(await authedAdminApi.get(`hooks/${created.id}`).json<Hook>()).toEqual(created);
expect(
await authedAdminApi
.patch(`hooks/${created.id}`, { json: { event: HookEvent.PostSignIn } })
.patch(`hooks/${created.id}`, { json: { event: InteractionHookEvent.PostSignIn } })
.json<Hook>()
).toMatchObject({
...created,
event: HookEvent.PostSignIn,
event: InteractionHookEvent.PostSignIn,
});
expect(await authedAdminApi.delete(`hooks/${created.id}`)).toHaveProperty('status', 204);
await expectRejects(authedAdminApi.get(`hooks/${created.id}`), {
@ -56,7 +56,7 @@ describe('hooks', () => {
});
it('should return hooks with pagination if pagination-related query params are provided', async () => {
const payload = getHookCreationPayload(HookEvent.PostRegister);
const payload = getHookCreationPayload(InteractionHookEvent.PostRegister);
const created = await authedAdminApi.post('hooks', { json: payload }).json<Hook>();
const response = await authedAdminApi.get('hooks?page=1&page_size=20');
@ -71,7 +71,7 @@ describe('hooks', () => {
it('should throw error when creating a hook with an empty hook name', async () => {
const payload = {
name: '',
events: [HookEvent.PostRegister],
events: [InteractionHookEvent.PostRegister],
config: {
url: 'not_work_url',
},

View file

@ -1,4 +1,4 @@
import { HookEvent, type Hook } from '@logto/schemas';
import { InteractionHookEvent, type Hook } from '@logto/schemas';
import { authedAdminApi } from '#src/api/api.js';
import { getHookCreationPayload } from '#src/helpers/hook.js';
@ -32,10 +32,13 @@ describe('hook testing', () => {
});
it('should return 204 if test hook successfully', async () => {
const payload = getHookCreationPayload(HookEvent.PostRegister, responseSuccessEndpoint);
const payload = getHookCreationPayload(
InteractionHookEvent.PostRegister,
responseSuccessEndpoint
);
const created = await authedAdminApi.post('hooks', { json: payload }).json<Hook>();
const response = await authedAdminApi.post(`hooks/${created.id}/test`, {
json: { events: [HookEvent.PostSignIn], config: { url: responseSuccessEndpoint } },
json: { events: [InteractionHookEvent.PostSignIn], config: { url: responseSuccessEndpoint } },
});
expect(response.status).toBe(204);
@ -47,7 +50,10 @@ describe('hook testing', () => {
const invalidHookId = 'invalid_id';
await expectRejects(
authedAdminApi.post(`hooks/${invalidHookId}/test`, {
json: { events: [HookEvent.PostSignIn], config: { url: responseSuccessEndpoint } },
json: {
events: [InteractionHookEvent.PostSignIn],
config: { url: responseSuccessEndpoint },
},
}),
{
code: 'entity.not_exists_with_id',
@ -57,11 +63,11 @@ describe('hook testing', () => {
});
it('should return 422 if the hook endpoint is not working', async () => {
const payload = getHookCreationPayload(HookEvent.PostRegister);
const payload = getHookCreationPayload(InteractionHookEvent.PostRegister);
const created = await authedAdminApi.post('hooks', { json: payload }).json<Hook>();
await expectRejects(
authedAdminApi.post(`hooks/${created.id}/test`, {
json: { events: [HookEvent.PostSignIn], config: { url: 'not_work_url' } },
json: { events: [InteractionHookEvent.PostSignIn], config: { url: 'not_work_url' } },
}),
{
code: 'hook.send_test_payload_failed',
@ -74,11 +80,11 @@ describe('hook testing', () => {
});
it('should return 422 and contains endpoint response if the hook endpoint return 500', async () => {
const payload = getHookCreationPayload(HookEvent.PostRegister);
const payload = getHookCreationPayload(InteractionHookEvent.PostRegister);
const created = await authedAdminApi.post('hooks', { json: payload }).json<Hook>();
await expectRejects(
authedAdminApi.post(`hooks/${created.id}/test`, {
json: { events: [HookEvent.PostSignIn], config: { url: responseErrorEndpoint } },
json: { events: [InteractionHookEvent.PostSignIn], config: { url: responseErrorEndpoint } },
}),
{
code: 'hook.endpoint_responded_with_error',

View file

@ -2,13 +2,14 @@ import { createHmac } from 'node:crypto';
import { type RequestListener } from 'node:http';
import {
type Hook,
HookEvent,
type LogKey,
ConnectorType,
InteractionHookEvent,
LogResult,
SignInIdentifier,
type Hook,
type Log,
ConnectorType,
type LogContextPayload,
type LogKey,
} from '@logto/schemas';
import { type Optional } from '@silverhand/essentials';
@ -58,6 +59,9 @@ const hookServerRequestListener: RequestListener = (request, response) => {
});
};
const assertHookLogError = ({ result, error }: LogContextPayload, errorMessage: string) =>
result === LogResult.Error && typeof error === 'string' && error.includes(errorMessage);
describe('trigger hooks', () => {
const { listen, close } = createMockServer(9999, hookServerRequestListener);
@ -76,7 +80,7 @@ describe('trigger hooks', () => {
it('should trigger sign-in hook and record error when interaction finished', async () => {
const createdHook = await authedAdminApi
.post('hooks', { json: getHookCreationPayload(HookEvent.PostSignIn) })
.post('hooks', { json: getHookCreationPayload(InteractionHookEvent.PostSignIn) })
.json<Hook>();
const logKey: LogKey = 'TriggerHook.PostSignIn';
@ -92,12 +96,15 @@ describe('trigger hooks', () => {
createdHook.id,
new URLSearchParams({ logKey, page_size: '100' })
);
expect(
logs.some(
({ payload: { result, error } }) =>
result === LogResult.Error && error === 'TypeError: Failed to parse URL from not_work_url'
)
).toBeTruthy();
const hookLog = logs.find(({ payload: { hookId } }) => hookId === createdHook.id);
expect(hookLog).toBeTruthy();
if (hookLog) {
expect(
assertHookLogError(hookLog.payload, 'Failed to parse URL from not_work_url')
).toBeTruthy();
}
// Clean up
await authedAdminApi.delete(`hooks/${createdHook.id}`);
@ -107,18 +114,18 @@ describe('trigger hooks', () => {
it('should trigger multiple register hooks and record properly when interaction finished', async () => {
const [hook1, hook2, hook3] = await Promise.all([
authedAdminApi
.post('hooks', { json: getHookCreationPayload(HookEvent.PostRegister) })
.post('hooks', { json: getHookCreationPayload(InteractionHookEvent.PostRegister) })
.json<Hook>(),
authedAdminApi
.post('hooks', {
json: getHookCreationPayload(HookEvent.PostRegister, 'http://localhost:9999'),
json: getHookCreationPayload(InteractionHookEvent.PostRegister, 'http://localhost:9999'),
})
.json<Hook>(),
// Using the old API to create a hook
authedAdminApi
.post('hooks', {
json: {
event: HookEvent.PostRegister,
event: InteractionHookEvent.PostRegister,
config: { url: 'http://localhost:9999', retries: 2 },
},
})
@ -137,7 +144,7 @@ describe('trigger hooks', () => {
// Check hook trigger log
for (const [hook, expectedResult, expectedError] of [
[hook1, LogResult.Error, 'TypeError: Failed to parse URL from not_work_url'],
[hook1, LogResult.Error, 'Failed to parse URL from not_work_url'],
[hook2, LogResult.Success, undefined],
[hook3, LogResult.Success, undefined],
] satisfies Array<[Hook, LogResult, Optional<string>]>) {
@ -147,17 +154,24 @@ describe('trigger hooks', () => {
new URLSearchParams({ logKey, page_size: '100' })
);
// Assert user ip is in the hook request
expect(
logs.every(({ payload }) => (payload.hookRequest as HookRequest).body.userIp)
).toBeTruthy();
const log = logs.find(({ payload: { hookId } }) => hookId === hook.id);
expect(
logs.some(
({ payload: { result, error } }) =>
result === expectedResult && (!expectedError || error === expectedError)
)
).toBeTruthy();
expect(log).toBeTruthy();
// Skip the test if the log is not found
if (!log) {
return;
}
// Assert user ip is in the hook request
expect((log.payload.hookRequest as HookRequest).body.userIp).toBeTruthy();
// Assert the log result and error message
expect(log.payload.result).toEqual(expectedResult);
if (expectedError) {
expect(assertHookLogError(log.payload, expectedError)).toBeTruthy();
}
}
// Clean up
@ -172,7 +186,7 @@ describe('trigger hooks', () => {
it('should secure webhook payload data successfully', async () => {
const createdHook = await authedAdminApi
.post('hooks', {
json: getHookCreationPayload(HookEvent.PostRegister, 'http://localhost:9999'),
json: getHookCreationPayload(InteractionHookEvent.PostRegister, 'http://localhost:9999'),
})
.json<Hook>();
@ -219,7 +233,10 @@ describe('trigger hooks', () => {
// Create a reset password hook
const resetPasswordHook = await authedAdminApi
.post('hooks', {
json: getHookCreationPayload(HookEvent.PostResetPassword, 'http://localhost:9999'),
json: getHookCreationPayload(
InteractionHookEvent.PostResetPassword,
'http://localhost:9999'
),
})
.json<Hook>();
const logKey: LogKey = 'TriggerHook.PostResetPassword';

View file

@ -1,17 +1,54 @@
import { z } from 'zod';
export enum HookEvent {
/**
* We categorize the hook events into two types:
*
* InteractionHookEvent: The hook events that are triggered by user interactions.
* DataHookEvent: The hook events that are triggered by Logto data mutations.
*/
// InteractionHookEvent
export enum InteractionHookEvent {
PostRegister = 'PostRegister',
PostSignIn = 'PostSignIn',
PostResetPassword = 'PostResetPassword',
}
export const hookEventGuard: z.ZodType<HookEvent> = z.nativeEnum(HookEvent);
// DataHookEvent
// TODO: @simeng-li implement more data hook events
enum DataHookMutableSchema {
Role = 'Role',
}
enum DataHookMutationType {
Created = 'Created',
Updated = 'Updated',
Deleted = 'Deleted',
}
export type DataHookEvent = `${DataHookMutableSchema}.${DataHookMutationType}`;
/** The hook event values that can be registered. */
export const hookEvents = Object.freeze([
InteractionHookEvent.PostRegister,
InteractionHookEvent.PostSignIn,
InteractionHookEvent.PostResetPassword,
'Role.Created',
'Role.Updated',
'Role.Deleted',
] as const satisfies Array<InteractionHookEvent | DataHookEvent>);
/** The type of hook event values that can be registered. */
export type HookEvent = (typeof hookEvents)[number];
export const hookEventGuard = z.enum(hookEvents);
export const hookEventsGuard = hookEventGuard.array();
export type HookEvents = z.infer<typeof hookEventsGuard>;
/**
* Hook configuration for web hook.
*/
export const hookConfigGuard = z.object({
/** We don't need `type` since v1 only has web hook */
// type: 'web';
@ -29,3 +66,15 @@ export const hookConfigGuard = z.object({
});
export type HookConfig = z.infer<typeof hookConfigGuard>;
type ApiMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
/**
* Management API hooks registration.
* Define the hook event that should be triggered when the management API is called.
*/
export const managementApiHooksRegistration = Object.freeze({
'POST /roles': 'Role.Created',
'PATCH /roles/:id': 'Role.Updated',
'DELETE /roles/:id': 'Role.Deleted',
} satisfies Record<`${ApiMethod} ${string}`, DataHookEvent>);

View file

@ -1,14 +1,14 @@
import { z } from 'zod';
import { Hooks, type Application, type User } from '../db-entries/index.js';
import { type HookEvent } from '../foundations/index.js';
import { type DataHookEvent, type InteractionHookEvent } from '../foundations/index.js';
import type { userInfoSelectFields } from './user.js';
export type HookEventPayload = {
hookId: string;
event: HookEvent;
export type InteractionHookEventPayload = {
event: InteractionHookEvent;
createdAt: string;
hookId: string;
sessionId?: string;
userAgent?: string;
userId?: string;
@ -17,6 +17,20 @@ export type HookEventPayload = {
application?: Pick<Application, 'id' | 'type' | 'name' | 'description'>;
} & Record<string, unknown>;
export type DataHookEventPayload = {
event: DataHookEvent;
createdAt: string;
hookId: string;
ip?: string;
userAgent?: string;
body?: Record<string, unknown>;
path?: string;
status?: number;
method?: string;
} & Record<string, unknown>;
export type HookEventPayload = InteractionHookEventPayload | DataHookEventPayload;
const hookExecutionStatsGuard = z.object({
successCount: z.number(),
requestCount: z.number(),

View file

@ -1,7 +1,8 @@
export * from './object.js';
export * from './ttl-cache.js';
export * from './fetch.js';
export * from './id.js';
export * from './user-display-name.js';
export * from './normalize-error.js';
export * from './object.js';
export * from './phone.js';
export * from './sub-domain.js';
export * from './fetch.js';
export * from './ttl-cache.js';
export * from './user-display-name.js';

View file

@ -0,0 +1,94 @@
/* Migrated from @logto/app-insights */
import { trySafe } from '@silverhand/essentials';
const transformedKey = Symbol('Indicates an object is transformed from an `Error` instance');
const isObject = (value: unknown): value is object => typeof value === 'object' && value !== null;
// Edited from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Cyclic_object_value#circular_references
function getCircularReplacer() {
const ancestors: unknown[] = [];
const transformedMap = new WeakMap<Error, unknown>();
const transformErrorValue = (value: unknown) => {
// Special handling for `Error` instances since they have non-enumerable properties
if (value instanceof Error) {
if (!transformedMap.has(value)) {
const transformed: Record<string | symbol, unknown> = {};
for (const key of Object.getOwnPropertyNames(value)) {
// @ts-expect-error getOwnPropertyNames() returns the valid keys
// eslint-disable-next-line @silverhand/fp/no-mutation
transformed[key] = value[key];
}
// eslint-disable-next-line @silverhand/fp/no-mutation
transformed[transformedKey] = true;
transformedMap.set(value, transformed);
}
return transformedMap.get(value);
}
return value;
};
return function (this: unknown, key: string, value: unknown) {
// Ignore `stack` property since ApplicationInsights will show it
if (isObject(this) && Object.hasOwn(this, transformedKey) && key === 'stack') {
return;
}
if (!isObject(value)) {
return value;
}
// `this` is the object that value is contained in,
// i.e., its direct parent.
while (ancestors.length > 0 && ancestors.at(-1) !== this) {
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
ancestors.pop();
}
const transformed = transformErrorValue(value);
if (ancestors.includes(transformed)) {
return '[Circular ~]';
}
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
ancestors.push(transformed);
return transformed;
};
}
/**
* Clone and stringify error object for logging purpose.
* The stringified result will be used as the error message.
* This is necessary because directly stringify an non-Error object will lose the stack trace.
*/
export const normalizeError = (error: unknown) => {
/**
* - Ensure the message if not empty otherwise Application Insights will respond 400
* and the error will not be recorded.
* - We stringify error object here since other error properties won't show on the
* ApplicationInsights details page.
*/
const message = trySafe(() => JSON.stringify(error, getCircularReplacer())) ?? 'Unknown error';
// Ensure we don't mutate the original error
const normalized = new Error(message);
if (error instanceof Error) {
// Manually clone key fields of the error for AppInsights display
/* eslint-disable @silverhand/fp/no-mutation */
normalized.name = error.name;
normalized.stack = error.stack;
normalized.cause = error.cause;
/* eslint-enable @silverhand/fp/no-mutation */
}
return normalized;
};

View file

@ -3234,6 +3234,9 @@ importers:
otplib:
specifier: ^12.0.1
version: 12.0.1
p-map:
specifier: ^7.0.2
version: 7.0.2
p-retry:
specifier: ^6.0.0
version: 6.0.0
@ -17929,6 +17932,11 @@ packages:
engines: {node: '>=6'}
dev: true
/p-map@7.0.2:
resolution: {integrity: sha512-z4cYYMMdKHzw4O5UkWJImbZynVIo0lSGTXc7bzB1e/rrDqkgGUNysK/o4bTr+0+xKvvLoTyGqYC4Fgljy9qe1Q==}
engines: {node: '>=18'}
dev: false
/p-queue@8.0.1:
resolution: {integrity: sha512-NXzu9aQJTAzbBqOt2hwsR63ea7yvxJc0PwN/zobNAudYfb1B7R08SzB4TsLeSbUCuG467NhnoT0oO6w1qRO+BA==}
engines: {node: '>=18'}