Skip to content

Commit 3b5f025

Browse files
authored
Merge pull request #32 from statelyai/davidkpiano/context-event-sanitization
Add sanitization
2 parents d3861d4 + 6f7b65a commit 3b5f025

File tree

3 files changed

+144
-6
lines changed

3 files changed

+144
-6
lines changed

.changeset/neat-beans-wave.md

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@statelyai/inspect": minor
3+
---
4+
5+
Added new options `sanitizeContext` and `sanitizeEvent` to the inspector configuration. These options allow users to sanitize sensitive data from the context and events before they are sent to the inspector, and also to remove non-serializable data.
6+
7+
Example usage:
8+
9+
```typescript
10+
const inspector = createInspector({
11+
sanitizeContext: (context) => {
12+
// Remove sensitive data from context
13+
const { password, ...safeContext } = context;
14+
return safeContext;
15+
},
16+
sanitizeEvent: (event) => {
17+
// Remove sensitive data from event
18+
if (event.type === 'SUBMIT_FORM') {
19+
const { creditCardNumber, ...safeEvent } = event;
20+
return safeEvent;
21+
}
22+
return event;
23+
}
24+
});

src/createInspector.test.ts

+63
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,69 @@ test('options.serialize', async () => {
260260
});
261261
});
262262

263+
test('Sanitization options', async () => {
264+
const events: StatelyInspectionEvent[] = [];
265+
const testAdapter: Adapter = {
266+
send: (event) => {
267+
events.push(event);
268+
},
269+
start: () => {},
270+
stop: () => {},
271+
};
272+
const inspector = createInspector(testAdapter, {
273+
sanitizeContext: (ctx) => ({
274+
...ctx,
275+
user: 'anonymous',
276+
}),
277+
sanitizeEvent: (ev) => {
278+
if ('user' in ev) {
279+
return { ...ev, user: 'anonymous' };
280+
} else {
281+
return ev;
282+
}
283+
},
284+
});
285+
286+
inspector.actor('test', { context: { user: 'David' } });
287+
288+
expect((events[0] as StatelyActorEvent).snapshot.context).toEqual({
289+
user: 'anonymous',
290+
});
291+
292+
inspector.snapshot('test', { context: { user: 'David' } });
293+
294+
expect((events[1] as StatelyActorEvent).snapshot.context).toEqual({
295+
user: 'anonymous',
296+
});
297+
298+
inspector.event('test', { type: 'updateUser', user: 'David' });
299+
300+
expect((events[2] as StatelyEventEvent).event).toEqual({
301+
type: 'updateUser',
302+
user: 'anonymous',
303+
});
304+
305+
inspector.inspect.next?.({
306+
type: '@xstate.event',
307+
actorRef: {} as any,
308+
event: {
309+
type: 'setUser',
310+
user: 'Another',
311+
},
312+
rootId: '',
313+
sourceRef: undefined,
314+
});
315+
316+
await new Promise<void>((res) => {
317+
setTimeout(res, 10);
318+
});
319+
320+
expect((events[3] as StatelyEventEvent).event).toEqual({
321+
type: 'setUser',
322+
user: 'anonymous',
323+
});
324+
});
325+
263326
test('it safely stringifies objects with circular dependencies', () => {
264327
const events: StatelyInspectionEvent[] = [];
265328
const testAdapter: Adapter = {

src/createInspector.ts

+57-6
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@ import {
77
} from './types';
88
import { toEventObject } from './utils';
99
import { Inspector } from './types';
10-
import { AnyActorRef, InspectionEvent, Snapshot } from 'xstate';
10+
import {
11+
AnyActorRef,
12+
AnyEventObject,
13+
InspectionEvent,
14+
MachineContext,
15+
Snapshot,
16+
} from 'xstate';
1117
import pkg from '../package.json';
1218
import { idleCallback } from './idleCallback';
1319
import safeStringify from 'safe-stable-stringify';
@@ -40,29 +46,74 @@ export interface InspectorOptions {
4046
* @default true
4147
*/
4248
autoStart?: boolean;
49+
/**
50+
* The maximum number of deferred events to hold in memory until the inspector is active.
51+
* If the number of deferred events exceeds this number, the oldest events will be dropped.
52+
*
53+
* @default 200
54+
*/
4355
maxDeferredEvents?: number;
56+
57+
/**
58+
* Sanitizes events sent to actors. Only the sanitized event will be sent to the inspector.
59+
*/
60+
sanitizeEvent?: (event: AnyEventObject) => AnyEventObject;
61+
62+
/**
63+
* Sanitizes actor snapshot context. Only the sanitized context will be sent to the inspector.
64+
*/
65+
sanitizeContext?: (context: MachineContext) => MachineContext;
4466
}
4567

4668
export const defaultInspectorOptions: Required<InspectorOptions> = {
4769
filter: () => true,
4870
serialize: (event) => event,
4971
autoStart: true,
5072
maxDeferredEvents: 200,
73+
sanitizeEvent: (event) => event,
74+
sanitizeContext: (context) => context,
5175
};
5276

5377
export function createInspector<TAdapter extends Adapter>(
5478
adapter: TAdapter,
5579
options?: InspectorOptions
5680
): Inspector<TAdapter> {
57-
function sendAdapter(event: StatelyInspectionEvent): void {
58-
if (options?.filter && !options.filter(event)) {
81+
function sendAdapter(inspectionEvent: StatelyInspectionEvent): void {
82+
if (options?.filter && !options.filter(inspectionEvent)) {
5983
// Event filtered out
6084
return;
6185
}
62-
const serializedEvent = options?.serialize?.(event) ?? event;
63-
// idleCallback(() => {
86+
87+
const sanitizedEvent: typeof inspectionEvent =
88+
options?.sanitizeContext || options?.sanitizeEvent
89+
? inspectionEvent
90+
: {
91+
...inspectionEvent,
92+
};
93+
if (
94+
options?.sanitizeContext &&
95+
(sanitizedEvent.type === '@xstate.actor' ||
96+
sanitizedEvent.type === '@xstate.snapshot')
97+
) {
98+
sanitizedEvent.snapshot = {
99+
...sanitizedEvent.snapshot,
100+
// @ts-ignore
101+
context: options.sanitizeContext(
102+
// @ts-ignore
103+
sanitizedEvent.snapshot.context
104+
),
105+
};
106+
}
107+
if (
108+
options?.sanitizeEvent &&
109+
(sanitizedEvent.type === '@xstate.event' ||
110+
sanitizedEvent.type === '@xstate.snapshot')
111+
) {
112+
sanitizedEvent.event = options.sanitizeEvent(sanitizedEvent.event);
113+
}
114+
const serializedEvent =
115+
options?.serialize?.(sanitizedEvent) ?? sanitizedEvent;
64116
adapter.send(serializedEvent);
65-
// })
66117
}
67118
const inspector: Inspector<TAdapter> = {
68119
adapter,

0 commit comments

Comments
 (0)