diff --git a/packages/controllers/src/index.ts b/packages/controllers/src/index.ts index fc6cb0e22f..62b4f5165b 100644 --- a/packages/controllers/src/index.ts +++ b/packages/controllers/src/index.ts @@ -2,3 +2,4 @@ export * from './permissions'; export * from './resource'; export * from './services'; export * from './snaps'; +export * from './subject-metadata'; diff --git a/packages/controllers/src/subject-metadata/SubjectMetadataController.test.ts b/packages/controllers/src/subject-metadata/SubjectMetadataController.test.ts new file mode 100644 index 0000000000..07475aa38f --- /dev/null +++ b/packages/controllers/src/subject-metadata/SubjectMetadataController.test.ts @@ -0,0 +1,232 @@ +import { ControllerMessenger, Json } from '@metamask/controllers'; +import { HasPermissions } from '../permissions'; +import { + SubjectMetadataController, + SubjectMetadataControllerActions, + SubjectMetadataControllerEvents, +} from './SubjectMetadataController'; + +const controllerName = 'SubjectMetadataController'; + +function getSubjectMetadataControllerMessenger() { + const controllerMessenger = new ControllerMessenger< + SubjectMetadataControllerActions | HasPermissions, + SubjectMetadataControllerEvents + >(); + + const hasPermissionsSpy = jest.fn(); + controllerMessenger.registerActionHandler( + 'PermissionController:hasPermissions', + hasPermissionsSpy, + ); + + return [ + controllerMessenger.getRestricted< + typeof controllerName, + SubjectMetadataControllerActions['type'] | HasPermissions['type'], + SubjectMetadataControllerEvents['type'] + >({ + name: controllerName, + allowedActions: [ + 'PermissionController:hasPermissions', + 'SubjectMetadataController:getState', + ], + }), + hasPermissionsSpy, + ] as const; +} + +function getSubjectMetadata( + origin: string, + name: string, + opts?: Record, +) { + return { + origin, + name, + iconUrl: null, + extensionId: null, + ...opts, + }; +} + +describe('SubjectMetadataController', () => { + describe('constructor', () => { + it('initializes a subject metadata controller', () => { + const controller = new SubjectMetadataController({ + messenger: getSubjectMetadataControllerMessenger()[0], + subjectCacheLimit: 10, + }); + expect(controller.state).toStrictEqual({ subjectMetadata: {} }); + }); + + it('trims subject metadata state on startup', () => { + const [messenger, hasPermissionsSpy] = + getSubjectMetadataControllerMessenger(); + hasPermissionsSpy.mockImplementationOnce(() => false); + hasPermissionsSpy.mockImplementationOnce(() => true); + + const controller = new SubjectMetadataController({ + messenger, + subjectCacheLimit: 10, + state: { + subjectMetadata: { + 'foo.com': getSubjectMetadata('foo.com', 'foo'), + 'bar.io': getSubjectMetadata('bar.io', 'bar'), + }, + }, + }); + + expect(controller.state).toStrictEqual({ + subjectMetadata: { 'bar.io': getSubjectMetadata('bar.io', 'bar') }, + }); + }); + + it('throws if the subject cache limit is invalid', () => { + [0, -1, 1.1].forEach((subjectCacheLimit) => { + expect( + () => + new SubjectMetadataController({ + messenger: getSubjectMetadataControllerMessenger()[0], + subjectCacheLimit, + }), + ).toThrow( + `subjectCacheLimit must be a positive integer. Received: "${subjectCacheLimit}"`, + ); + }); + }); + }); + + describe('clearState', () => { + it('clears the controller state, and continues to function normally afterwards', () => { + const [messenger, hasPermissionsSpy] = + getSubjectMetadataControllerMessenger(); + const controller = new SubjectMetadataController({ + messenger, + subjectCacheLimit: 3, + }); + + // No subject will have permissions. + hasPermissionsSpy.mockImplementation(() => false); + + // Add subjects up to the cache limit + controller.addSubjectMetadata(getSubjectMetadata('foo.com', 'foo')); + controller.addSubjectMetadata(getSubjectMetadata('bar.com', 'bar')); + controller.addSubjectMetadata(getSubjectMetadata('baz.com', 'baz')); + + expect(Object.keys(controller.state.subjectMetadata)).toHaveLength(3); + + // Clear the state + controller.clearState(); + expect(Object.keys(controller.state.subjectMetadata)).toHaveLength(0); + + // Add another subject, which also does not have any permissions + controller.addSubjectMetadata(getSubjectMetadata('fizz.com', 'fizz')); + + // Observe that the subject was added normally + expect(controller.state).toStrictEqual({ + subjectMetadata: { 'fizz.com': getSubjectMetadata('fizz.com', 'fizz') }, + }); + }); + }); + + describe('addSubjectMetadata', () => { + it('adds subject metadata', () => { + const controller = new SubjectMetadataController({ + messenger: getSubjectMetadataControllerMessenger()[0], + subjectCacheLimit: 10, + }); + + controller.addSubjectMetadata(getSubjectMetadata('foo.com', 'foo')); + expect(controller.state).toStrictEqual({ + subjectMetadata: { 'foo.com': getSubjectMetadata('foo.com', 'foo') }, + }); + }); + + it('fills in missing fields of added subject metadata', () => { + const controller = new SubjectMetadataController({ + messenger: getSubjectMetadataControllerMessenger()[0], + subjectCacheLimit: 10, + }); + + controller.addSubjectMetadata({ origin: 'foo.com', name: 'foo' }); + expect(controller.state).toStrictEqual({ + subjectMetadata: { 'foo.com': getSubjectMetadata('foo.com', 'foo') }, + }); + }); + + it('does not delete metadata for subjects with permissions if cache size is exceeded', () => { + const [messenger, hasPermissionsSpy] = + getSubjectMetadataControllerMessenger(); + const controller = new SubjectMetadataController({ + messenger, + subjectCacheLimit: 1, + }); + hasPermissionsSpy.mockImplementationOnce(() => true); + + controller.addSubjectMetadata({ origin: 'foo.com', name: 'foo' }); + controller.addSubjectMetadata({ origin: 'bar.io', name: 'bar' }); + expect(controller.state).toStrictEqual({ + subjectMetadata: { + 'foo.com': getSubjectMetadata('foo.com', 'foo'), + 'bar.io': getSubjectMetadata('bar.io', 'bar'), + }, + }); + }); + + it('deletes metadata for subjects without permissions if cache size is exceeded', () => { + const [messenger, hasPermissionsSpy] = + getSubjectMetadataControllerMessenger(); + const controller = new SubjectMetadataController({ + messenger, + subjectCacheLimit: 1, + }); + hasPermissionsSpy.mockImplementationOnce(() => false); + + controller.addSubjectMetadata({ origin: 'foo.com', name: 'foo' }); + controller.addSubjectMetadata({ origin: 'bar.io', name: 'bar' }); + expect(controller.state).toStrictEqual({ + subjectMetadata: { + 'bar.io': getSubjectMetadata('bar.io', 'bar'), + }, + }); + }); + }); + + describe('trimMetadataState', () => { + it('deletes all subjects without permissions from state', () => { + const [messenger, hasPermissionsSpy] = + getSubjectMetadataControllerMessenger(); + const controller = new SubjectMetadataController({ + messenger, + subjectCacheLimit: 4, + }); + + controller.addSubjectMetadata({ origin: 'A', name: 'a' }); + controller.addSubjectMetadata({ origin: 'B', name: 'b' }); + controller.addSubjectMetadata({ origin: 'C', name: 'c' }); + controller.addSubjectMetadata({ origin: 'D', name: 'd' }); + expect(controller.state).toStrictEqual({ + subjectMetadata: { + A: getSubjectMetadata('A', 'a'), + B: getSubjectMetadata('B', 'b'), + C: getSubjectMetadata('C', 'c'), + D: getSubjectMetadata('D', 'd'), + }, + }); + + hasPermissionsSpy.mockImplementationOnce(() => true); + hasPermissionsSpy.mockImplementationOnce(() => false); + hasPermissionsSpy.mockImplementationOnce(() => false); + hasPermissionsSpy.mockImplementationOnce(() => true); + + controller.trimMetadataState(); + expect(controller.state).toStrictEqual({ + subjectMetadata: { + A: getSubjectMetadata('A', 'a'), + D: getSubjectMetadata('D', 'd'), + }, + }); + }); + }); +}); diff --git a/packages/controllers/src/subject-metadata/SubjectMetadataController.ts b/packages/controllers/src/subject-metadata/SubjectMetadataController.ts new file mode 100644 index 0000000000..bf9033bf52 --- /dev/null +++ b/packages/controllers/src/subject-metadata/SubjectMetadataController.ts @@ -0,0 +1,223 @@ +import { + BaseControllerV2 as BaseController, + RestrictedControllerMessenger, +} from '@metamask/controllers'; +import type { Patch } from 'immer'; +import { Json } from 'json-rpc-engine'; + +import type { + GenericPermissionController, + PermissionSubjectMetadata, + HasPermissions, +} from '../permissions'; + +const controllerName = 'SubjectMetadataController'; + +type SubjectOrigin = string; + +export type SubjectMetadata = PermissionSubjectMetadata & { + [key: string]: Json; + name: string; + // TODO:TS4.4 make optional + extensionId: string | null; + iconUrl: string | null; +}; + +type SubjectMetadataToAdd = PermissionSubjectMetadata & { + name: string; + extensionId?: string | null; + iconUrl?: string | null; +} & Record; + +export type SubjectMetadataControllerState = { + subjectMetadata: Record; +}; + +const stateMetadata = { + subjectMetadata: { persist: true, anonymous: false }, +}; + +const defaultState: SubjectMetadataControllerState = { + subjectMetadata: {}, +}; + +export type GetSubjectMetadataState = { + type: `${typeof controllerName}:getState`; + handler: () => SubjectMetadataControllerState; +}; + +export type SubjectMetadataControllerActions = GetSubjectMetadataState; + +export type SubjectMetadataStateChange = { + type: `${typeof controllerName}:stateChange`; + payload: [SubjectMetadataControllerState, Patch[]]; +}; + +export type SubjectMetadataControllerEvents = SubjectMetadataStateChange; + +type AllowedActions = HasPermissions; + +export type SubjectMetadataControllerMessenger = RestrictedControllerMessenger< + typeof controllerName, + SubjectMetadataControllerActions | AllowedActions, + SubjectMetadataControllerEvents, + AllowedActions['type'], + never +>; + +type SubjectMetadataControllerOptions = { + messenger: SubjectMetadataControllerMessenger; + subjectCacheLimit: number; + state?: Partial; +}; + +/** + * A controller for storing metadata associated with permission subjects. More + * or less, a cache. + */ +export class SubjectMetadataController extends BaseController< + typeof controllerName, + SubjectMetadataControllerState, + SubjectMetadataControllerMessenger +> { + private subjectCacheLimit: number; + + private subjectsWithoutPermissionsEcounteredSinceStartup: Set; + + private subjectHasPermissions: GenericPermissionController['hasPermissions']; + + constructor({ + messenger, + subjectCacheLimit, + state = {}, + }: SubjectMetadataControllerOptions) { + if (!Number.isInteger(subjectCacheLimit) || subjectCacheLimit < 1) { + throw new Error( + `subjectCacheLimit must be a positive integer. Received: "${subjectCacheLimit}"`, + ); + } + + const hasPermissions = (origin: string) => { + return messenger.call('PermissionController:hasPermissions', origin); + }; + + super({ + name: controllerName, + metadata: stateMetadata, + messenger, + state: { + ...SubjectMetadataController.getTrimmedState(state, hasPermissions), + }, + }); + + this.subjectHasPermissions = hasPermissions; + this.subjectCacheLimit = subjectCacheLimit; + this.subjectsWithoutPermissionsEcounteredSinceStartup = new Set(); + } + + /** + * Clears the state of this controller. Also resets the cache of subjects + * encountered since startup, so as to not prematurely reach the cache limit. + */ + clearState(): void { + this.subjectsWithoutPermissionsEcounteredSinceStartup.clear(); + this.update((_draftState) => { + return { ...defaultState }; + }); + } + + /** + * Stores domain metadata for the given origin (subject). Deletes metadata for + * subjects without permissions in a FIFO manner once more than + * {@link SubjectMetadataController.subjectCacheLimit} distinct origins have + * been added since boot. + * + * In order to prevent a degraded user experience, + * metadata is never deleted for subjects with permissions, since metadata + * cannot yet be requested on demand. + * + * @param metadata - The subject metadata to store. + */ + addSubjectMetadata(metadata: SubjectMetadataToAdd): void { + const { origin } = metadata; + const newMetadata: SubjectMetadata = { + ...metadata, + extensionId: metadata.extensionId || null, + iconUrl: metadata.iconUrl || null, + }; + + let originToForget: string | null = null; + // We only delete the oldest encountered subject from the cache, again to + // ensure that the user's experience isn't degraded by missing icons etc. + if ( + this.subjectsWithoutPermissionsEcounteredSinceStartup.size >= + this.subjectCacheLimit + ) { + const cachedOrigin = this.subjectsWithoutPermissionsEcounteredSinceStartup + .values() + .next().value; + + this.subjectsWithoutPermissionsEcounteredSinceStartup.delete( + cachedOrigin, + ); + + if (!this.subjectHasPermissions(cachedOrigin)) { + originToForget = cachedOrigin; + } + } + + this.subjectsWithoutPermissionsEcounteredSinceStartup.add(origin); + + this.update((draftState) => { + // Typecast: ts(2589) + draftState.subjectMetadata[origin] = newMetadata as any; + if (typeof originToForget === 'string') { + delete draftState.subjectMetadata[originToForget]; + } + }); + } + + /** + * Deletes all subjects without permissions from the controller's state. + */ + trimMetadataState(): void { + this.update((draftState) => { + return SubjectMetadataController.getTrimmedState( + // Typecast: ts(2589) + draftState as any, + this.subjectHasPermissions, + ); + }); + } + + /** + * Returns a new state object that only includes subjects with permissions. + * This method is static because we want to call it in the constructor, before + * the controller's state is initialized. + * + * @param state - The state object to trim. + * @param hasPermissions - A function that returns a boolean indicating + * whether a particular subject (identified by its origin) has any + * permissions. + * @returns The new state object. If the specified `state` object has no + * subject metadata, the returned object will be equivalent to the default + * state of this controller. + */ + private static getTrimmedState( + state: Partial, + hasPermissions: SubjectMetadataController['subjectHasPermissions'], + ): SubjectMetadataControllerState { + const { subjectMetadata = {} } = state; + + return { + subjectMetadata: Object.keys(subjectMetadata).reduce< + Record + >((newSubjectMetadata, origin) => { + if (hasPermissions(origin)) { + newSubjectMetadata[origin] = subjectMetadata[origin]; + } + return newSubjectMetadata; + }, {}), + }; + } +} diff --git a/packages/controllers/src/subject-metadata/index.ts b/packages/controllers/src/subject-metadata/index.ts new file mode 100644 index 0000000000..3b0bcaa14b --- /dev/null +++ b/packages/controllers/src/subject-metadata/index.ts @@ -0,0 +1 @@ +export * from './SubjectMetadataController';