diff --git a/.changeset/flat-parrots-crash.md b/.changeset/flat-parrots-crash.md new file mode 100644 index 00000000000..ca0a95bb210 --- /dev/null +++ b/.changeset/flat-parrots-crash.md @@ -0,0 +1,7 @@ +--- +'slate': minor +'slate-react': minor +--- + +Add new Slate.Scrubber interface to allow scrubbing end user data from exception +text. The default behavior remains unchanged. diff --git a/docs/Summary.md b/docs/Summary.md index e539f71104b..6185eac86ea 100644 --- a/docs/Summary.md +++ b/docs/Summary.md @@ -48,6 +48,7 @@ - [RangeRef](api/locations/range-ref.md) - [Span](api/locations/span.md) - [Operation](api/operation.md) +- [Scrubber](api/scrubber.md) ## Libraries diff --git a/docs/api/scrubber.md b/docs/api/scrubber.md new file mode 100644 index 00000000000..71ad659c9ce --- /dev/null +++ b/docs/api/scrubber.md @@ -0,0 +1,31 @@ +# Scrubber API + +When Slate throws an exception, it includes a stringified representation of the +relevant data. For example, if your application makes an API call to access the +child of a text Node (an impossible operation), Slate will throw an exception +like this: + +``` +Cannot get the child of a text node: {"text": "This is my text node."} +``` + +If your rich text editor can include sensitive customer data, you may want to +scrub or obfuscate that text. To help with that, you can use the Scrubber API. +Here's an example of recursively scrubbing the `'text'` fields of any entity +that gets logged. + +```typescript +import { Scrubber } from 'slate' + +Scrubber.setScrubber((key, value) => { + if (key === 'text') return '... scrubbed ...' + return value +}) +``` + +By setting the scrubber in this way, the error example given above will be +printed as + +``` +Cannot get the child of a text node: {"text": "... scrubbed ..."} +``` diff --git a/packages/slate-react/src/components/slate.tsx b/packages/slate-react/src/components/slate.tsx index 7d157ee8a14..9e21a9d61b8 100644 --- a/packages/slate-react/src/components/slate.tsx +++ b/packages/slate-react/src/components/slate.tsx @@ -1,5 +1,5 @@ -import React, { useMemo, useState, useCallback, useEffect, useRef } from 'react' -import { Editor, Node, Element, Descendant } from 'slate' +import React, { useState, useCallback, useEffect, useRef } from 'react' +import { Editor, Node, Descendant, Scrubber } from 'slate' import { ReactEditor } from '../plugin/react-editor' import { FocusedContext } from '../hooks/use-focused' import { EditorContext } from '../hooks/use-slate-static' @@ -30,12 +30,13 @@ export const Slate = (props: { if (!Node.isNodeList(value)) { throw new Error( `[Slate] value is invalid! Expected a list of elements` + - `but got: ${JSON.stringify(value)}` + `but got: ${Scrubber.stringify(value)}` ) } if (!Editor.isEditor(editor)) { throw new Error( - `[Slate] editor is invalid! you passed:` + `${JSON.stringify(editor)}` + `[Slate] editor is invalid! you passed:` + + `${Scrubber.stringify(editor)}` ) } editor.children = value diff --git a/packages/slate-react/src/plugin/react-editor.ts b/packages/slate-react/src/plugin/react-editor.ts index 67803824963..0e7111a0998 100644 --- a/packages/slate-react/src/plugin/react-editor.ts +++ b/packages/slate-react/src/plugin/react-editor.ts @@ -1,4 +1,13 @@ -import { Editor, Node, Path, Point, Range, Transforms, BaseEditor } from 'slate' +import { + BaseEditor, + Editor, + Node, + Path, + Point, + Range, + Scrubber, + Transforms, +} from 'slate' import { Key } from '../utils/key' import { @@ -108,7 +117,7 @@ export const ReactEditor = { } throw new Error( - `Unable to find the path for Slate node: ${JSON.stringify(node)}` + `Unable to find the path for Slate node: ${Scrubber.stringify(node)}` ) }, @@ -285,7 +294,7 @@ export const ReactEditor = { if (!domNode) { throw new Error( - `Cannot resolve a DOM node from Slate node: ${JSON.stringify(node)}` + `Cannot resolve a DOM node from Slate node: ${Scrubber.stringify(node)}` ) } @@ -337,7 +346,9 @@ export const ReactEditor = { if (!domPoint) { throw new Error( - `Cannot resolve a DOM point from Slate point: ${JSON.stringify(point)}` + `Cannot resolve a DOM point from Slate point: ${Scrubber.stringify( + point + )}` ) } diff --git a/packages/slate/src/index.ts b/packages/slate/src/index.ts index 20880711460..73c70a952e1 100644 --- a/packages/slate/src/index.ts +++ b/packages/slate/src/index.ts @@ -1,4 +1,5 @@ export * from './create-editor' +export * from './interfaces/custom-types' export * from './interfaces/editor' export * from './interfaces/element' export * from './interfaces/location' @@ -10,6 +11,6 @@ export * from './interfaces/point' export * from './interfaces/point-ref' export * from './interfaces/range' export * from './interfaces/range-ref' +export * from './interfaces/scrubber' export * from './interfaces/text' -export * from './interfaces/custom-types' export * from './transforms' diff --git a/packages/slate/src/interfaces/node.ts b/packages/slate/src/interfaces/node.ts index 7489b094a8d..a316e88c81d 100644 --- a/packages/slate/src/interfaces/node.ts +++ b/packages/slate/src/interfaces/node.ts @@ -1,5 +1,5 @@ import { produce } from 'immer' -import { Editor, Path, Range, Text } from '..' +import { Editor, Path, Range, Text, Scrubber } from '..' import { Element, ElementEntry } from './element' /** @@ -112,7 +112,9 @@ export const Node: NodeInterface = { if (Text.isText(node)) { throw new Error( - `Cannot get the ancestor node at path [${path}] because it refers to a text node instead: ${node}` + `Cannot get the ancestor node at path [${path}] because it refers to a text node instead: ${Scrubber.stringify( + node + )}` ) } @@ -145,7 +147,7 @@ export const Node: NodeInterface = { child(root: Node, index: number): Descendant { if (Text.isText(root)) { throw new Error( - `Cannot get the child of a text node: ${JSON.stringify(root)}` + `Cannot get the child of a text node: ${Scrubber.stringify(root)}` ) } @@ -153,7 +155,7 @@ export const Node: NodeInterface = { if (c == null) { throw new Error( - `Cannot get child at index \`${index}\` in node: ${JSON.stringify( + `Cannot get child at index \`${index}\` in node: ${Scrubber.stringify( root )}` ) @@ -203,7 +205,9 @@ export const Node: NodeInterface = { if (Editor.isEditor(node)) { throw new Error( - `Cannot get the descendant node at path [${path}] because it refers to the root editor node instead: ${node}` + `Cannot get the descendant node at path [${path}] because it refers to the root editor node instead: ${Scrubber.stringify( + node + )}` ) } @@ -287,7 +291,7 @@ export const Node: NodeInterface = { fragment(root: Node, range: Range): Descendant[] { if (Text.isText(root)) { throw new Error( - `Cannot get a fragment starting from a root text node: ${JSON.stringify( + `Cannot get a fragment starting from a root text node: ${Scrubber.stringify( root )}` ) @@ -339,7 +343,7 @@ export const Node: NodeInterface = { if (Text.isText(node) || !node.children[p]) { throw new Error( - `Cannot find a descendant at path [${path}] in node: ${JSON.stringify( + `Cannot find a descendant at path [${path}] in node: ${Scrubber.stringify( root )}` ) @@ -428,7 +432,9 @@ export const Node: NodeInterface = { if (!Text.isText(node)) { throw new Error( - `Cannot get the leaf node at path [${path}] because it refers to a non-leaf node: ${node}` + `Cannot get the leaf node at path [${path}] because it refers to a non-leaf node: ${Scrubber.stringify( + node + )}` ) } diff --git a/packages/slate/src/interfaces/scrubber.ts b/packages/slate/src/interfaces/scrubber.ts new file mode 100644 index 00000000000..5ed99b005fe --- /dev/null +++ b/packages/slate/src/interfaces/scrubber.ts @@ -0,0 +1,33 @@ +export type Scrubber = (key: string, value: unknown) => unknown + +export interface ScrubberInterface { + setScrubber(scrubber: Scrubber | undefined): void + stringify(value: any): string +} + +let _scrubber: Scrubber | undefined = undefined + +/** + * This interface implements a stringify() function, which is used by Slate + * internally when generating exceptions containing end user data. Developers + * using Slate may call Scrubber.setScrubber() to alter the behavior of this + * stringify() function. + * + * For example, to prevent the cleartext logging of 'text' fields within Nodes: + * + * import { Scrubber } from 'slate'; + * Scrubber.setScrubber((key, val) => { + * if (key === 'text') return '...scrubbed...' + * return val + * }); + * + */ +export const Scrubber: ScrubberInterface = { + setScrubber(scrubber: Scrubber | undefined): void { + _scrubber = scrubber + }, + + stringify(value: any): string { + return JSON.stringify(value, _scrubber) + }, +} diff --git a/packages/slate/src/transforms/general.ts b/packages/slate/src/transforms/general.ts index dea89c243b7..c9d1dce76e1 100644 --- a/packages/slate/src/transforms/general.ts +++ b/packages/slate/src/transforms/general.ts @@ -1,17 +1,18 @@ import { createDraft, finishDraft, isDraft } from 'immer' import { - Node, + Ancestor, + Descendant, Editor, - Selection, - Range, - Point, - Text, Element, - Operation, - Descendant, + Node, NodeEntry, + Operation, Path, - Ancestor, + Point, + Range, + Scrubber, + Selection, + Text, } from '..' export interface GeneralTransforms { @@ -73,7 +74,9 @@ const applyToDraft = (editor: Editor, selection: Selection, op: Operation) => { prev.children.push(...node.children) } else { throw new Error( - `Cannot apply a "merge_node" operation at path [${path}] to nodes of different interfaces: ${node} ${prev}` + `Cannot apply a "merge_node" operation at path [${path}] to nodes of different interfaces: ${Scrubber.stringify( + node + )} ${Scrubber.stringify(prev)}` ) } @@ -236,7 +239,7 @@ const applyToDraft = (editor: Editor, selection: Selection, op: Operation) => { if (selection == null) { if (!Range.isRange(newProperties)) { throw new Error( - `Cannot apply an incomplete "set_selection" operation properties ${JSON.stringify( + `Cannot apply an incomplete "set_selection" operation properties ${Scrubber.stringify( newProperties )} when there is no current selection.` ) diff --git a/packages/slate/src/transforms/node.ts b/packages/slate/src/transforms/node.ts index 7574ebc843e..c3bfa46949a 100644 --- a/packages/slate/src/transforms/node.ts +++ b/packages/slate/src/transforms/node.ts @@ -1,15 +1,16 @@ import { + Ancestor, Editor, Element, Location, Node, + NodeEntry, Path, Point, Range, + Scrubber, Text, Transforms, - NodeEntry, - Ancestor, } from '..' import { NodeMatch, PropsCompare, PropsMerge } from '../interfaces/editor' import { PointRef } from '../interfaces/point-ref' @@ -406,9 +407,9 @@ export const NodeTransforms: NodeTransforms = { properties = rest as Partial } else { throw new Error( - `Cannot merge the node at path [${path}] with the previous sibling because it is not the same kind: ${JSON.stringify( + `Cannot merge the node at path [${path}] with the previous sibling because it is not the same kind: ${Scrubber.stringify( node - )} ${JSON.stringify(prevNode)}` + )} ${Scrubber.stringify(prevNode)}` ) } diff --git a/packages/slate/src/transforms/selection.ts b/packages/slate/src/transforms/selection.ts index 91275caed21..5cfaac45f1d 100644 --- a/packages/slate/src/transforms/selection.ts +++ b/packages/slate/src/transforms/selection.ts @@ -1,4 +1,4 @@ -import { Editor, Location, Point, Range, Transforms } from '..' +import { Editor, Location, Point, Range, Scrubber, Transforms } from '..' import { SelectionEdge, MoveUnit } from '../interfaces/types' export interface SelectionCollapseOptions { @@ -132,7 +132,7 @@ export const SelectionTransforms: SelectionTransforms = { if (!Range.isRange(target)) { throw new Error( - `When setting the selection and the current selection is \`null\` you must provide at least an \`anchor\` and \`focus\`, but you passed: ${JSON.stringify( + `When setting the selection and the current selection is \`null\` you must provide at least an \`anchor\` and \`focus\`, but you passed: ${Scrubber.stringify( target )}` ) diff --git a/packages/slate/test/interfaces/Scrubber/scrubber.ts b/packages/slate/test/interfaces/Scrubber/scrubber.ts new file mode 100644 index 00000000000..d600890be47 --- /dev/null +++ b/packages/slate/test/interfaces/Scrubber/scrubber.ts @@ -0,0 +1,22 @@ +import { Node, Scrubber } from 'slate' + +export const input = { + customField: 'some very long custom field value that will get scrubbed', + anotherField: 'this field should not get scrambled', +} + +export const test = (value: Node) => { + Scrubber.setScrubber((key, value) => key == 'customField' ? '... scrubbed ...' : value); + const stringified = Scrubber.stringify(value) + Scrubber.setScrubber(undefined) + + const unmarshaled = JSON.parse(stringified) + return ( + // ensure that first field has been scrubbed + unmarshaled.customField === '... scrubbed ...' && + // ensure that second field is unaltered + unmarshaled.anotherField === input.anotherField + ) +} + +export const output = true