Repository: blitz-js/superjson (ts)

Issue:
Add ability to allow error props only for specific errors.
Below is shown a common pattern for creating custom error objects.

**Problem:** `allowErrorProps` applies to **all** errors which could be a security issue if another error has sensitive data in the prop that you have allowed.

**Suggestion:** Add ability to allow props with the `registerClass` function:

```ts
SuperJson.registerClass(UsernameTakenError, {allowProps: ['suggestedUserName']})
```

```ts
import SuperJson from "superjson")

export class UsernameTakenError extends Error {
  name = "UsernameTakenError"
  constructor({suggestedUserName}) {
    super()
    this.suggestedUserName = suggestedUserName
  }
}
// Register with SuperJson serializer so it's reconstructed on the client
SuperJson.registerClass(UsernameTakenError)
SuperJson.allowErrorProps("suggestedUserName")
```

Pull request description:
#104: per-class allowProps
Closes #104

Patch:
diff --git a/src/class-registry.ts b/src/class-registry.ts
index 1f12fd0..211a757 100644
--- a/src/class-registry.ts
+++ b/src/class-registry.ts
@@ -1,4 +1,33 @@
 import { Registry } from './registry';
 import { Class } from './types';
 
-export const ClassRegistry = new Registry<Class>(c => c.name);
+export interface RegisterOptions {
+  identifier?: string;
+  allowProps?: string[];
+}
+
+class _ClassRegistry extends Registry<Class> {
+  constructor() {
+    super(c => c.name);
+  }
+
+  private classToAllowedProps = new Map<Class, string[]>();
+
+  register(value: Class, options?: string | RegisterOptions): void {
+    if (typeof options === 'object') {
+      if (options.allowProps) {
+        this.classToAllowedProps.set(value, options.allowProps);
+      }
+
+      super.register(value, options.identifier);
+    } else {
+      super.register(value, options);
+    }
+  }
+
+  getAllowedProps(value: Class): string[] | undefined {
+    return this.classToAllowedProps.get(value);
+  }
+}
+
+export const ClassRegistry = new _ClassRegistry();
diff --git a/src/index.ts b/src/index.ts
index 52caaac..ab3fe24 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -8,7 +8,7 @@ import {
   Class,
   JSONValue,
 } from './types';
-import { ClassRegistry } from './class-registry';
+import { ClassRegistry, RegisterOptions } from './class-registry';
 import { SymbolRegistry } from './symbol-registry';
 import {
   CustomTransfomer,
@@ -52,8 +52,8 @@ const stringify = (object: SuperJSONValue): string =>
 export const parse = <T = unknown>(string: string): T =>
   deserialize(JSON.parse(string));
 
-const registerClass = (v: Class, identifier?: string) =>
-  ClassRegistry.register(v, identifier);
+const registerClass = (v: Class, options?: RegisterOptions) =>
+  ClassRegistry.register(v, options);
 
 const registerSymbol = (v: Symbol, identifier?: string) =>
   SymbolRegistry.register(v, identifier);
diff --git a/src/transformer.ts b/src/transformer.ts
index aee8956..0b8d26a 100644
--- a/src/transformer.ts
+++ b/src/transformer.ts
@@ -232,7 +232,18 @@ const classRule = compositeTransformation(
     const identifier = ClassRegistry.getIdentifier(clazz.constructor);
     return ['class', identifier!];
   },
-  v => v,
+  clazz => {
+    const allowedProps = ClassRegistry.getAllowedProps(clazz.constructor);
+    if (!allowedProps) {
+      return clazz;
+    }
+
+    const result: any = {};
+    allowedProps.forEach(prop => {
+      result[prop] = clazz[prop];
+    });
+    return result;
+  },
   (v, a) => {
     const clazz = ClassRegistry.getValue(a[1]);
 


Test patch:
diff --git a/src/index.test.ts b/src/index.test.ts
index 0a33c9c..3cc5d27 100644
--- a/src/index.test.ts
+++ b/src/index.test.ts
@@ -16,6 +16,7 @@ describe('stringify & parse', () => {
       output: JSONValue | ((v: JSONValue) => void);
       outputAnnotations?: Annotations;
       customExpectations?: (value: any) => void;
+      dontExpectEquality?: boolean;
     }
   > = {
     'works for objects': {
@@ -514,6 +515,27 @@ describe('stringify & parse', () => {
       },
     },
 
+    'works with custom allowedProps': {
+      input: () => {
+        class User {
+          constructor(public username: string, public password: string) {}
+        }
+        SuperJSON.registerClass(User, { allowProps: ['username'] });
+        return new User('bongocat', 'supersecurepassword');
+      },
+      output: {
+        username: 'bongocat',
+      },
+      outputAnnotations: {
+        values: [['class', 'User']],
+      },
+      customExpectations(value) {
+        expect(value.password).toBeUndefined();
+        expect(value.username).toBe('bongocat');
+      },
+      dontExpectEquality: true,
+    },
+
     'works for undefined, issue #48': {
       input: undefined,
       output: null,
@@ -561,6 +583,7 @@ describe('stringify & parse', () => {
       output: expectedOutput,
       outputAnnotations: expectedOutputAnnotations,
       customExpectations,
+      dontExpectEquality,
     },
   ] of Object.entries(cases)) {
     test(testName, () => {
@@ -578,7 +601,9 @@ describe('stringify & parse', () => {
       expect(meta).toEqual(expectedOutputAnnotations);
 
       const untransformed = SuperJSON.deserialize({ json, meta });
-      expect(untransformed).toEqual(inputValue);
+      if (!dontExpectEquality) {
+        expect(untransformed).toEqual(inputValue);
+      }
       customExpectations?.(untransformed);
     });
   }