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:
parent
0d8d3a0d95
commit
21bb35b127
30 changed files with 768 additions and 191 deletions
10
.changeset/fluffy-steaks-flow.md
Normal file
10
.changeset/fluffy-steaks-flow.md
Normal 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.
|
7
.changeset/metal-lions-swim.md
Normal file
7
.changeset/metal-lions-swim.md
Normal 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.
|
|
@ -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,
|
||||
}));
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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])) ?? '-';
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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(', ')
|
||||
);
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
|
|
24
packages/core/src/libraries/hook/context-manager.ts
Normal file
24
packages/core/src/libraries/hook/context-manager.ts
Normal 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
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
28
packages/core/src/libraries/hook/types.ts
Normal file
28
packages/core/src/libraries/hook/types.ts
Normal 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,
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 },
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
66
packages/core/src/middleware/koa-management-api-hooks.ts
Normal file
66
packages/core/src/middleware/koa-management-api-hooks.ts
Normal 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));
|
||||
}
|
||||
};
|
||||
};
|
|
@ -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 });
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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>();
|
||||
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>);
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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';
|
||||
|
|
94
packages/shared/src/utils/normalize-error.ts
Normal file
94
packages/shared/src/utils/normalize-error.ts
Normal 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;
|
||||
};
|
|
@ -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'}
|
||||
|
|
Loading…
Reference in a new issue